@cloudcare/hvigor-ohos-plugin 0.1.1 → 0.1.2-alpha.2
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/CHANGELOG.md +15 -1
- package/README.md +53 -6
- package/hvigorCompile/ft-cold-launch-transform.ts +668 -121
- package/hvigorCompile/ft-console-log-transform.ts +90 -96
- package/hvigorCompile/ft-generated-names.ts +19 -0
- package/hvigorCompile/ft-lexical-scope.ts +65 -0
- package/hvigorCompile/ft-session-replay-transform.ts +381 -0
- package/hvigorCompile/ft-source-transform-host.ts +112 -23
- package/hvigorCompile/ft-transform-context.ts +91 -0
- package/hvigorCompile/ft-web-replay-transform.ts +61 -0
- package/index.js +5 -3
- package/package.json +12 -3
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guance Session Replay page-root compiler transform.
|
|
3
|
+
*
|
|
4
|
+
* ArkUI exposes a FrameNode only after a component has an ID. The transform
|
|
5
|
+
* instruments @Entry page roots in Hvigor's in-memory source, leaving customer
|
|
6
|
+
* files untouched. The generated calls are deliberately tiny: the optional
|
|
7
|
+
* Replay HAR owns root selection, layout retries, privacy, and capture.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isApplicationArkTsSource } from './ft-console-log-transform';
|
|
11
|
+
import { hasRuntimeDependency, SourceReader, SourceDiagnosticReporter, parseApplicationSource,
|
|
12
|
+
reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
|
|
13
|
+
import { allocateName as selectUniqueName } from './ft-generated-names';
|
|
14
|
+
|
|
15
|
+
type TypeScriptApi = any;
|
|
16
|
+
|
|
17
|
+
interface SourceRange {
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MethodRange {
|
|
23
|
+
nameStart: number;
|
|
24
|
+
memberStart: number;
|
|
25
|
+
node: any;
|
|
26
|
+
bodyStart: number;
|
|
27
|
+
bodyEnd: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RootExpression {
|
|
31
|
+
end: number;
|
|
32
|
+
idArgument?: SourceRange;
|
|
33
|
+
hasAreaChange: boolean;
|
|
34
|
+
hasTouch: boolean;
|
|
35
|
+
touchArgument?: SourceRange;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface EntryStruct {
|
|
39
|
+
node: any;
|
|
40
|
+
bodyEnd: number;
|
|
41
|
+
methods: Map<string, MethodRange>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Injection {
|
|
45
|
+
position: number;
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const REPLAY_BRIDGE_IMPORT = '@guancecloud/ft_session_replay';
|
|
50
|
+
const REPLAY_ROOT_TRANSFORM_MARKER = '/* guance-session-replay-root-transform:instrumented */';
|
|
51
|
+
|
|
52
|
+
function lineIndentation(code: string, position: number): string {
|
|
53
|
+
const lineStart = code.lastIndexOf('\n', position) + 1;
|
|
54
|
+
const match = code.slice(lineStart, position).match(/^\s*/);
|
|
55
|
+
return match === null ? '' : match[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function collectIdentifiers(code: string): Set<string> {
|
|
59
|
+
const identifiers = new Set<string>();
|
|
60
|
+
const expression = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g;
|
|
61
|
+
let match: RegExpExecArray | null;
|
|
62
|
+
while ((match = expression.exec(code)) !== null) {
|
|
63
|
+
identifiers.add(match[0]);
|
|
64
|
+
}
|
|
65
|
+
return identifiers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A stable opaque source key. Do not emit customers' project paths into UI IDs. */
|
|
69
|
+
function createSourceKey(id: string): string {
|
|
70
|
+
let hash = 0x811c9dc5;
|
|
71
|
+
const normalized = id.replace(/\\/g, '/').toLowerCase();
|
|
72
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
73
|
+
hash ^= normalized.charCodeAt(index);
|
|
74
|
+
hash = Math.imul(hash, 0x01000193);
|
|
75
|
+
}
|
|
76
|
+
return (hash >>> 0).toString(36);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findImportInsertionOffset(ts: TypeScriptApi, id: string, code: string): number {
|
|
80
|
+
const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
|
|
81
|
+
ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
|
|
82
|
+
let offset = 0;
|
|
83
|
+
sourceFile.statements.forEach((statement: any): void => {
|
|
84
|
+
if (ts.isImportDeclaration(statement)) {
|
|
85
|
+
offset = statement.end;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return offset;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findEntryDecorator(ts: TypeScriptApi, source: any): any {
|
|
92
|
+
let entry: any;
|
|
93
|
+
const visit = (node: any): void => {
|
|
94
|
+
if (ts.isDecorator(node)) {
|
|
95
|
+
const expression = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
|
|
96
|
+
if (ts.isIdentifier(expression) && expression.text === 'Entry') entry ??= node;
|
|
97
|
+
}
|
|
98
|
+
ts.forEachChild(node, visit);
|
|
99
|
+
};
|
|
100
|
+
visit(source);
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Structs, decorators and members must come from the ArkTS parser, never text matches. */
|
|
105
|
+
function collectEntryStructs(ts: TypeScriptApi, source: any): EntryStruct[] {
|
|
106
|
+
const entries: EntryStruct[] = [];
|
|
107
|
+
source.statements.forEach((node: any) => {
|
|
108
|
+
if (!ts.isStructDeclaration(node)) return;
|
|
109
|
+
const decorators = ts.getDecorators ? ts.getDecorators(node) || [] : node.decorators || [];
|
|
110
|
+
const isEntry = decorators.some((decorator: any) => {
|
|
111
|
+
const expression = ts.isCallExpression(decorator.expression)
|
|
112
|
+
? decorator.expression.expression : decorator.expression;
|
|
113
|
+
return ts.isIdentifier(expression) && expression.text === 'Entry';
|
|
114
|
+
});
|
|
115
|
+
if (!isEntry) return;
|
|
116
|
+
const methods = new Map<string, MethodRange>();
|
|
117
|
+
node.members.forEach((member: any) => {
|
|
118
|
+
if (ts.isMethodDeclaration(member) && member.body && ts.isIdentifier(member.name)) {
|
|
119
|
+
methods.set(member.name.text, { nameStart: member.name.getStart(source),
|
|
120
|
+
memberStart: member.getStart(source), node: member,
|
|
121
|
+
bodyStart: member.body.getStart(source), bodyEnd: member.body.end - 1 });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
entries.push({ node, bodyEnd: node.end - 1, methods });
|
|
125
|
+
});
|
|
126
|
+
return entries;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function componentChain(ts: TypeScriptApi, expression: any): { base: any; modifiers: any[] } {
|
|
130
|
+
const modifiers: any[] = [];
|
|
131
|
+
let base = expression;
|
|
132
|
+
while (ts.isCallExpression(base) && ts.isPropertyAccessExpression(base.expression)) {
|
|
133
|
+
modifiers.unshift(base);
|
|
134
|
+
base = base.expression.expression;
|
|
135
|
+
}
|
|
136
|
+
return { base, modifiers };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function argumentRange(source: any, call: any): SourceRange {
|
|
140
|
+
// Include comments and newlines before the closing parenthesis when reusing an argument.
|
|
141
|
+
return { start: call.arguments.pos, end: call.getLastToken(source).getStart(source) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findRootExpression(ts: TypeScriptApi, source: any, code: string,
|
|
145
|
+
build: MethodRange): RootExpression | null {
|
|
146
|
+
const first = build.node.body.statements[0];
|
|
147
|
+
if (!first || (!ts.isExpressionStatement(first) && !ts.isReturnStatement(first)) || !first.expression) return null;
|
|
148
|
+
const { base, modifiers } = componentChain(ts, first.expression);
|
|
149
|
+
if (!ts.isEtsComponentExpression(base) || !base.body) return null;
|
|
150
|
+
let idArgument: SourceRange | undefined;
|
|
151
|
+
let hasAreaChange = false;
|
|
152
|
+
let hasTouch = false;
|
|
153
|
+
let touchArgument: SourceRange | undefined;
|
|
154
|
+
modifiers.forEach((call: any) => {
|
|
155
|
+
const name = call.expression.name.text;
|
|
156
|
+
const range = argumentRange(source, call);
|
|
157
|
+
if (name === 'id' && call.arguments.length === 1) {
|
|
158
|
+
const argument = call.arguments[0];
|
|
159
|
+
idArgument = { start: argument.getStart(source), end: argument.end };
|
|
160
|
+
}
|
|
161
|
+
if (name === 'onAreaChange') hasAreaChange = true;
|
|
162
|
+
if (name === 'onTouch') {
|
|
163
|
+
hasTouch = true;
|
|
164
|
+
if (call.arguments.length === 1) touchArgument = range;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return { end: first.expression.end, idArgument, hasAreaChange, hasTouch, touchArgument };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyInjections(code: string, injections: Injection[]): string {
|
|
171
|
+
const grouped = new Map<number, string[]>();
|
|
172
|
+
injections.forEach((injection: Injection): void => {
|
|
173
|
+
const values = grouped.get(injection.position) || [];
|
|
174
|
+
values.push(injection.content);
|
|
175
|
+
grouped.set(injection.position, values);
|
|
176
|
+
});
|
|
177
|
+
const positions = Array.from(grouped.keys()).sort((left: number, right: number): number => right - left);
|
|
178
|
+
let transformed = code;
|
|
179
|
+
positions.forEach((position: number): void => {
|
|
180
|
+
transformed = transformed.slice(0, position) + (grouped.get(position) || []).join('') + transformed.slice(position);
|
|
181
|
+
});
|
|
182
|
+
return transformed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function addTransitionInjections(ts: TypeScriptApi, source: any, code: string, injections: Injection[],
|
|
186
|
+
entry: EntryStruct, bridgeName: string, tokenField: string, rootExpression: string): void {
|
|
187
|
+
const method = entry.methods.get('pageTransition');
|
|
188
|
+
if (!method) {
|
|
189
|
+
const indentation = `${lineIndentation(code, entry.bodyEnd)} `;
|
|
190
|
+
injections.push({ position: entry.bodyEnd, content: `\n${indentation}pageTransition(): void {\n` +
|
|
191
|
+
`${indentation} if (${bridgeName}.defaultTransitionStarted(this.${tokenField})) {}\n${indentation}}\n` });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
let nextSpec = 0;
|
|
195
|
+
method.node.body.statements.forEach((statement: any) => {
|
|
196
|
+
if (!ts.isExpressionStatement(statement)) return;
|
|
197
|
+
const { base, modifiers } = componentChain(ts, statement.expression);
|
|
198
|
+
if ((!ts.isCallExpression(base) && !ts.isEtsComponentExpression(base)) || !ts.isIdentifier(base.expression)) return;
|
|
199
|
+
const name = base.expression.text;
|
|
200
|
+
if (name !== 'PageTransitionEnter' && name !== 'PageTransitionExit') return;
|
|
201
|
+
const entering = name === 'PageTransitionEnter';
|
|
202
|
+
const callbackName = entering ? 'onEnter' : 'onExit';
|
|
203
|
+
const spec = JSON.stringify(`transition-${nextSpec++}`);
|
|
204
|
+
const prefix = `this.${tokenField}, ${rootExpression}, ${spec}, ${entering}`;
|
|
205
|
+
let callback: SourceRange | undefined;
|
|
206
|
+
modifiers.forEach((call: any) => {
|
|
207
|
+
const modifierName = call.expression.name.text;
|
|
208
|
+
const range = argumentRange(source, call);
|
|
209
|
+
if ((modifierName === 'slide' || modifierName === 'opacity') && call.arguments.length === 1) {
|
|
210
|
+
const hook = modifierName === 'slide' ? 'transitionSlide' : 'transitionOpacity';
|
|
211
|
+
injections.push({ position: range.start,
|
|
212
|
+
content: `${bridgeName}.${hook}(this.${tokenField}, ${spec}, ` });
|
|
213
|
+
injections.push({ position: range.end, content: ')' });
|
|
214
|
+
} else if (modifierName === callbackName && call.arguments.length === 1) callback = range;
|
|
215
|
+
});
|
|
216
|
+
if (callback) {
|
|
217
|
+
injections.push({ position: callback.start, content: `${bridgeName}.wrapTransition(${prefix}, ` });
|
|
218
|
+
injections.push({ position: callback.end, content: ')' });
|
|
219
|
+
} else {
|
|
220
|
+
injections.push({ position: statement.expression.end,
|
|
221
|
+
content: `.${callbackName}((type: RouteType, progress: number) => { ${bridgeName}.transitionProgress(${prefix}, progress); })` });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function lifecycleCall(bridgeName: string, tokenField: string, method: 'pageShown' | 'pageHidden' | 'pageDisposed',
|
|
227
|
+
rootExpression: string, observeDefaultTransition: boolean): string {
|
|
228
|
+
if (method === 'pageShown') {
|
|
229
|
+
return `${bridgeName}.pageShown(this.getUIContext(), this.${tokenField}, ${rootExpression}, vp2px(1), ${observeDefaultTransition});`;
|
|
230
|
+
}
|
|
231
|
+
return `${bridgeName}.${method}(this.${tokenField});`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function addLifecycleInjection(
|
|
235
|
+
code: string,
|
|
236
|
+
injections: Injection[],
|
|
237
|
+
struct: EntryStruct,
|
|
238
|
+
buildMethod: MethodRange,
|
|
239
|
+
bridgeName: string,
|
|
240
|
+
tokenField: string,
|
|
241
|
+
methodName: 'onPageShow' | 'onPageHide' | 'aboutToDisappear',
|
|
242
|
+
runtimeMethod: 'pageShown' | 'pageHidden' | 'pageDisposed',
|
|
243
|
+
rootExpression: string,
|
|
244
|
+
visibleField?: string
|
|
245
|
+
): void {
|
|
246
|
+
const existing = struct.methods.get(methodName);
|
|
247
|
+
const rawCall = lifecycleCall(bridgeName, tokenField, runtimeMethod, rootExpression,
|
|
248
|
+
!struct.methods.has('pageTransition'));
|
|
249
|
+
const call = !visibleField ? rawCall : methodName === 'onPageShow'
|
|
250
|
+
? `this.${visibleField} = true; if (${rootExpression}.length > 0) { ${rawCall} }`
|
|
251
|
+
: `this.${visibleField} = false; ${rawCall}`;
|
|
252
|
+
if (existing) {
|
|
253
|
+
const indentation = `${lineIndentation(code, existing.nameStart)} `;
|
|
254
|
+
injections.push({ position: existing.bodyStart + 1, content: `\n${indentation}${call}` });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const indentation = lineIndentation(code, buildMethod.nameStart);
|
|
258
|
+
injections.push({
|
|
259
|
+
position: buildMethod.memberStart,
|
|
260
|
+
content: `\n${indentation}${methodName}(): void {\n${indentation} ${call}\n${indentation}}\n\n`
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Adds an opaque root ID and page lifecycle registration to every @Entry page.
|
|
266
|
+
* ArkTS nodes locate real decorators, struct members and component expressions.
|
|
267
|
+
*/
|
|
268
|
+
export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader,
|
|
269
|
+
reportDiagnostic?: SourceDiagnosticReporter): string {
|
|
270
|
+
if (!isApplicationArkTsSource(id) || code.includes(REPLAY_ROOT_TRANSFORM_MARKER) ||
|
|
271
|
+
!hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
|
|
272
|
+
!hasRuntimeDependency(ts, id, REPLAY_BRIDGE_IMPORT, readSource)) {
|
|
273
|
+
return code;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const preliminarySource = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
|
|
277
|
+
ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
|
|
278
|
+
const report = (source: any, position: number, reason: string, message: string): void => {
|
|
279
|
+
reportSourceDiagnostic(source, position, reason, message, reportDiagnostic);
|
|
280
|
+
};
|
|
281
|
+
if (typeof ts.isStructDeclaration !== 'function' || typeof ts.isEtsComponentExpression !== 'function') {
|
|
282
|
+
// Even a standard TypeScript parser retains real decorator nodes. Do not
|
|
283
|
+
// mistake @Entry text inside comments or strings for a page needing Replay.
|
|
284
|
+
const entryDecorator = findEntryDecorator(ts, preliminarySource);
|
|
285
|
+
if (entryDecorator) report(preliminarySource, entryDecorator.getStart(preliminarySource), 'unsupported-parser',
|
|
286
|
+
'The installed compiler does not expose the ArkTS struct/component parser required for automatic Replay roots.');
|
|
287
|
+
return code;
|
|
288
|
+
}
|
|
289
|
+
const source = parseApplicationSource(ts, id, code, preliminarySource);
|
|
290
|
+
const entries = collectEntryStructs(ts, source);
|
|
291
|
+
if (entries.length === 0 && !findEntryDecorator(ts, source)) return code;
|
|
292
|
+
if (reportSourceParseError(ts, source, reportDiagnostic)) return code;
|
|
293
|
+
|
|
294
|
+
const identifiers = collectIdentifiers(code);
|
|
295
|
+
const bridgeName = selectUniqueName(identifiers, '__ftSessionReplayAutoRootBridge');
|
|
296
|
+
const tokenField = selectUniqueName(identifiers, '__ftSessionReplayRootToken');
|
|
297
|
+
const sourceKey = createSourceKey(id);
|
|
298
|
+
const injections: Injection[] = [];
|
|
299
|
+
|
|
300
|
+
entries.forEach((entry: EntryStruct): void => {
|
|
301
|
+
const buildMethod = entry.methods.get('build');
|
|
302
|
+
if (!buildMethod) {
|
|
303
|
+
report(source, entry.node.name.getStart(source), 'missing-build',
|
|
304
|
+
'The @Entry page has no build() method with a body to instrument.');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const root = findRootExpression(ts, source, code, buildMethod);
|
|
308
|
+
if (root === null) {
|
|
309
|
+
const first = buildMethod.node.body.statements[0];
|
|
310
|
+
report(source, first ? first.getStart(source) : buildMethod.bodyStart + 1, 'unsupported-root',
|
|
311
|
+
'build() must start with a supported ArkUI root component with a child block for automatic Replay registration.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const indentation = lineIndentation(code, buildMethod.nameStart);
|
|
315
|
+
injections.push({
|
|
316
|
+
position: buildMethod.memberStart,
|
|
317
|
+
content: `\n${indentation}private ${tokenField}: string = ${bridgeName}.createRootToken(${JSON.stringify(sourceKey)});\n\n`
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
let rootExpression = `this.${tokenField}`;
|
|
321
|
+
let visibleField: string | undefined;
|
|
322
|
+
if (root.idArgument) {
|
|
323
|
+
const idField = selectUniqueName(identifiers, '__ftSessionReplayRootId');
|
|
324
|
+
visibleField = selectUniqueName(identifiers, '__ftSessionReplayVisible');
|
|
325
|
+
const capture = selectUniqueName(identifiers, '__ftSessionReplayCaptureId');
|
|
326
|
+
const value = selectUniqueName(identifiers, '__ftSessionReplayIdValue');
|
|
327
|
+
rootExpression = `this.${idField}`;
|
|
328
|
+
const shown = lifecycleCall(bridgeName, tokenField, 'pageShown', rootExpression,
|
|
329
|
+
!entry.methods.has('pageTransition'));
|
|
330
|
+
injections.push({ position: buildMethod.memberStart,
|
|
331
|
+
content: `\n${indentation}private ${idField}: string = '';\n` +
|
|
332
|
+
`${indentation}private ${visibleField}: boolean = false;\n` +
|
|
333
|
+
`${indentation}private ${capture}(${value}: string): string {\n` +
|
|
334
|
+
`${indentation} const changed: boolean = this.${idField} !== ${value};\n` +
|
|
335
|
+
`${indentation} this.${idField} = ${value};\n` +
|
|
336
|
+
`${indentation} if (changed && this.${visibleField} && ${value}.length > 0) { ${shown} }\n` +
|
|
337
|
+
`${indentation} return ${value};\n${indentation}}\n` });
|
|
338
|
+
injections.push({ position: root.idArgument.start, content: `this.${capture}(` });
|
|
339
|
+
injections.push({ position: root.idArgument.end, content: ')' });
|
|
340
|
+
} else {
|
|
341
|
+
injections.push({ position: root.end, content: `.id(${rootExpression})` });
|
|
342
|
+
}
|
|
343
|
+
// Root area changes are ArkUI's public layout/draw boundary. Emit a
|
|
344
|
+
// coalesced Replay signal there instead of installing a fixed full-tree
|
|
345
|
+
// polling loop. Do not add a second modifier when an app already owns one.
|
|
346
|
+
if (!root.hasAreaChange) {
|
|
347
|
+
injections.push({
|
|
348
|
+
position: root.end,
|
|
349
|
+
content: `.onAreaChange(() => { ${bridgeName}.pageChanged(this.${tokenField}); })`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
// Preserve application callbacks and their event propagation behavior.
|
|
353
|
+
// The bridge forwards real pointer coordinates independently of drawing.
|
|
354
|
+
if (!root.hasTouch) {
|
|
355
|
+
injections.push({
|
|
356
|
+
position: root.end,
|
|
357
|
+
content: `.onTouch((event: TouchEvent) => { ${bridgeName}.pageTouch(this.${tokenField}, event); })`
|
|
358
|
+
});
|
|
359
|
+
} else if (root.touchArgument !== undefined) {
|
|
360
|
+
injections.push({ position: root.touchArgument.start,
|
|
361
|
+
content: `${bridgeName}.wrapTouch(this.${tokenField}, ` });
|
|
362
|
+
injections.push({ position: root.touchArgument.end, content: ')' });
|
|
363
|
+
}
|
|
364
|
+
addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
|
|
365
|
+
'onPageShow', 'pageShown', rootExpression, visibleField);
|
|
366
|
+
addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
|
|
367
|
+
'onPageHide', 'pageHidden', rootExpression, visibleField);
|
|
368
|
+
addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
|
|
369
|
+
'aboutToDisappear', 'pageDisposed', rootExpression, visibleField);
|
|
370
|
+
addTransitionInjections(ts, source, code, injections, entry, bridgeName, tokenField, rootExpression);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (injections.length === 0) {
|
|
374
|
+
return code;
|
|
375
|
+
}
|
|
376
|
+
const importOffset = findImportInsertionOffset(ts, id, code);
|
|
377
|
+
const importStatement = `\n${REPLAY_ROOT_TRANSFORM_MARKER}\n` +
|
|
378
|
+
`import { FTSessionReplayAutoRootBridge as ${bridgeName} } from '${REPLAY_BRIDGE_IMPORT}';\n`;
|
|
379
|
+
injections.push({ position: importOffset, content: importStatement });
|
|
380
|
+
return applyInjections(code, injections);
|
|
381
|
+
}
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
transformConsoleLogSource
|
|
13
13
|
} from './ft-console-log-transform';
|
|
14
14
|
import { transformColdLaunchSource } from './ft-cold-launch-transform';
|
|
15
|
+
import { transformSessionReplaySource } from './ft-session-replay-transform';
|
|
16
|
+
import { transformWebReplaySource } from './ft-web-replay-transform';
|
|
17
|
+
import { SourceDiagnosticReporter, SourceTransformDiagnostic } from './ft-transform-context';
|
|
15
18
|
|
|
16
19
|
declare const require: {
|
|
17
20
|
main: {
|
|
@@ -20,7 +23,9 @@ declare const require: {
|
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
type TypeScriptApi = any;
|
|
23
|
-
type
|
|
26
|
+
type SourceReader = (fileName: string) => string | undefined;
|
|
27
|
+
type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string, readSource?: SourceReader,
|
|
28
|
+
reportDiagnostic?: SourceDiagnosticReporter) => string;
|
|
24
29
|
|
|
25
30
|
interface CompilePluginContext {
|
|
26
31
|
share: {
|
|
@@ -32,6 +37,8 @@ interface CompilePluginContext {
|
|
|
32
37
|
|
|
33
38
|
interface SourceTransformDefinition {
|
|
34
39
|
id: string;
|
|
40
|
+
prefix: string;
|
|
41
|
+
feature: string;
|
|
35
42
|
transform: SourceTransform;
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -39,21 +46,64 @@ interface SharedSourceTransformRegistry {
|
|
|
39
46
|
originalReadFile: (fileName: string, options?: any) => any;
|
|
40
47
|
buffer: any;
|
|
41
48
|
transforms: Map<string, (fileName: string, code: string) => string>;
|
|
49
|
+
owners: Map<object, Map<string, (fileName: string, code: string) => string>>;
|
|
50
|
+
reportedDiagnostics: Set<string>;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
const SOURCE_TRANSFORM_REGISTRY_KEY = '__guanceHvigorSourceTransformRegistry';
|
|
45
54
|
const SOURCE_TRANSFORMS: SourceTransformDefinition[] = [
|
|
46
|
-
{ id: 'guance-console-log-transform', transform: transformConsoleLogSource },
|
|
47
|
-
{ id: 'guance-cold-launch-transform', transform: transformColdLaunchSource }
|
|
55
|
+
{ id: 'guance-console-log-transform', prefix: 'GuanceLog', feature: 'console-log', transform: transformConsoleLogSource },
|
|
56
|
+
{ id: 'guance-cold-launch-transform', prefix: 'GuanceColdLaunch', feature: 'cold-launch', transform: transformColdLaunchSource },
|
|
57
|
+
{ id: 'guance-session-replay-root-transform', prefix: 'GuanceReplay', feature: 'session-replay-root', transform: transformSessionReplaySource },
|
|
58
|
+
{ id: 'guance-web-replay-transform', prefix: 'GuanceWebReplay', feature: 'web-replay', transform: transformWebReplaySource }
|
|
48
59
|
];
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
const registrations: Array<{ fileSystem: any; owner: object; context: CompilePluginContext }> = [];
|
|
51
62
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
function refreshTransforms(registry: SharedSourceTransformRegistry): void {
|
|
64
|
+
registry.transforms.clear();
|
|
65
|
+
registry.owners.forEach(transforms => transforms.forEach((transform, id) => registry.transforms.set(id, transform)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createDiagnosticReporter(registry: SharedSourceTransformRegistry, ts: TypeScriptApi,
|
|
69
|
+
readSource: SourceReader, definition: SourceTransformDefinition):
|
|
70
|
+
(diagnostic: SourceTransformDiagnostic, inputFile: string, inputCode: string, occurrence?: number) => void {
|
|
71
|
+
let logger: { warn(message: string): void } | undefined;
|
|
72
|
+
return (diagnostic: SourceTransformDiagnostic, inputFile: string, inputCode: string, occurrence = 0): void => {
|
|
73
|
+
// Earlier transforms can insert imports or wrappers. Recheck only failed
|
|
74
|
+
// pages against the on-disk source so clickable locations refer to app code.
|
|
75
|
+
const originalCode = readSource(diagnostic.fileName);
|
|
76
|
+
if (diagnostic.reason !== 'transform-exception' && diagnostic.fileName === inputFile &&
|
|
77
|
+
originalCode !== undefined && originalCode !== inputCode) {
|
|
78
|
+
const originalDiagnostics: SourceTransformDiagnostic[] = [];
|
|
79
|
+
try {
|
|
80
|
+
definition.transform(ts, diagnostic.fileName, originalCode, readSource, candidate => {
|
|
81
|
+
if (candidate.reason === diagnostic.reason &&
|
|
82
|
+
(candidate.message === diagnostic.message || candidate.reason === 'parse-error')) originalDiagnostics.push(candidate);
|
|
83
|
+
});
|
|
84
|
+
} catch (_) {
|
|
85
|
+
// Location mapping is diagnostic-only and must not change build behavior.
|
|
86
|
+
}
|
|
87
|
+
diagnostic = originalDiagnostics[occurrence] ?? { ...diagnostic,
|
|
88
|
+
message: `${diagnostic.message} (Location refers to in-memory transformed source.)` };
|
|
89
|
+
}
|
|
90
|
+
const key = `${definition.id}:${JSON.stringify(diagnostic)}`;
|
|
91
|
+
if (registry.reportedDiagnostics.has(key)) return;
|
|
92
|
+
registry.reportedDiagnostics.add(key);
|
|
93
|
+
const message = `[GuanceBuild][${definition.prefix}] ${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column}: ` +
|
|
94
|
+
`feature=${definition.feature}; reason=${diagnostic.reason}; ${diagnostic.message}`;
|
|
95
|
+
if (!logger) {
|
|
96
|
+
try {
|
|
97
|
+
logger = require.main.require('@ohos/hvigor').HvigorLogger.getLogger(definition.prefix);
|
|
98
|
+
} catch (_) {
|
|
99
|
+
logger = { warn: (value: string): void => console.warn(value) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
logger!.warn(message);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
56
105
|
|
|
106
|
+
function installSourceReader(context: CompilePluginContext): void {
|
|
57
107
|
const typeScriptPath = `${context.share.projectConfig.etsLoaderPath}/node_modules/typescript`;
|
|
58
108
|
const ts = require.main.require(typeScriptPath);
|
|
59
109
|
const fileSystem = require.main.require('fs');
|
|
@@ -62,7 +112,9 @@ function installSourceReader(context: CompilePluginContext): void {
|
|
|
62
112
|
registry = {
|
|
63
113
|
originalReadFile: fileSystem.readFileSync as (fileName: string, options?: any) => any,
|
|
64
114
|
buffer: require.main.require('buffer'),
|
|
65
|
-
transforms: new Map<string, (fileName: string, code: string) => string>()
|
|
115
|
+
transforms: new Map<string, (fileName: string, code: string) => string>(),
|
|
116
|
+
owners: new Map(),
|
|
117
|
+
reportedDiagnostics: new Set()
|
|
66
118
|
};
|
|
67
119
|
fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] = registry;
|
|
68
120
|
fileSystem.readFileSync = (fileName: string, options?: any): any => {
|
|
@@ -78,26 +130,63 @@ function installSourceReader(context: CompilePluginContext): void {
|
|
|
78
130
|
};
|
|
79
131
|
}
|
|
80
132
|
|
|
133
|
+
const readSource: SourceReader = (fileName: string): string | undefined => {
|
|
134
|
+
try {
|
|
135
|
+
return registry!.originalReadFile(fileName, 'utf8').toString();
|
|
136
|
+
} catch (_) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
// A daemon may retain a registry installed by an earlier plugin version.
|
|
141
|
+
registry.reportedDiagnostics ??= new Set();
|
|
142
|
+
const owner = {};
|
|
143
|
+
const transforms = new Map<string, (fileName: string, code: string) => string>();
|
|
81
144
|
SOURCE_TRANSFORMS.forEach((definition: SourceTransformDefinition) => {
|
|
82
|
-
|
|
83
|
-
|
|
145
|
+
const reportDiagnostic = createDiagnosticReporter(registry!, ts, readSource, definition);
|
|
146
|
+
transforms.set(definition.id, (fileName: string, code: string) => {
|
|
147
|
+
const occurrences = new Map<string, number>();
|
|
148
|
+
try {
|
|
149
|
+
return definition.transform(ts, fileName, code, readSource,
|
|
150
|
+
diagnostic => {
|
|
151
|
+
const key = `${diagnostic.reason}:${diagnostic.message}`;
|
|
152
|
+
const occurrence = occurrences.get(key) ?? 0;
|
|
153
|
+
occurrences.set(key, occurrence + 1);
|
|
154
|
+
reportDiagnostic(diagnostic, fileName, code, occurrence);
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
try {
|
|
158
|
+
reportDiagnostic({ fileName, line: 1, column: 1, reason: 'transform-exception',
|
|
159
|
+
message: `Source transform failed: ${error instanceof Error ? error.message : String(error)}` }, fileName, code);
|
|
160
|
+
} finally {
|
|
161
|
+
// Preserve the original failure: diagnostics must never turn a broken
|
|
162
|
+
// transform into a successful build or replace its exception.
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
84
167
|
});
|
|
85
|
-
|
|
168
|
+
registry.owners.set(owner, transforms);
|
|
169
|
+
refreshTransforms(registry);
|
|
170
|
+
registrations.push({ fileSystem, owner, context });
|
|
86
171
|
}
|
|
87
172
|
|
|
88
|
-
function restoreSourceReader(): void {
|
|
89
|
-
|
|
90
|
-
|
|
173
|
+
function restoreSourceReader(context: CompilePluginContext): void {
|
|
174
|
+
let index = registrations.length - 1;
|
|
175
|
+
for (let candidate = registrations.length - 1; candidate >= 0; candidate--) {
|
|
176
|
+
if (registrations[candidate].context === context) { index = candidate; break; }
|
|
91
177
|
}
|
|
92
|
-
const
|
|
178
|
+
const registration = index < 0 ? undefined : registrations.splice(index, 1)[0];
|
|
179
|
+
if (!registration) return;
|
|
180
|
+
const { fileSystem, owner } = registration;
|
|
181
|
+
const registry = fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] as SharedSourceTransformRegistry | undefined;
|
|
93
182
|
if (registry) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
183
|
+
registry.owners.delete(owner);
|
|
184
|
+
refreshTransforms(registry);
|
|
185
|
+
if (registry.owners.size === 0) {
|
|
186
|
+
fileSystem.readFileSync = registry.originalReadFile;
|
|
187
|
+
delete fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY];
|
|
98
188
|
}
|
|
99
189
|
}
|
|
100
|
-
registeredFileSystem = null;
|
|
101
190
|
}
|
|
102
191
|
|
|
103
192
|
export default {
|
|
@@ -106,7 +195,7 @@ export default {
|
|
|
106
195
|
buildStart(this: CompilePluginContext): void {
|
|
107
196
|
installSourceReader(this);
|
|
108
197
|
},
|
|
109
|
-
buildEnd(): void {
|
|
110
|
-
restoreSourceReader();
|
|
198
|
+
buildEnd(this: CompilePluginContext): void {
|
|
199
|
+
restoreSourceReader(this);
|
|
111
200
|
}
|
|
112
201
|
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Shared, source-module-specific guards for compiler transforms. */
|
|
2
|
+
declare const require: any;
|
|
3
|
+
|
|
4
|
+
export type SourceReader = (fileName: string) => string | undefined;
|
|
5
|
+
|
|
6
|
+
export interface SourceTransformDiagnostic {
|
|
7
|
+
fileName: string;
|
|
8
|
+
line: number;
|
|
9
|
+
column: number;
|
|
10
|
+
reason: string;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SourceDiagnosticReporter = (diagnostic: SourceTransformDiagnostic) => void;
|
|
15
|
+
|
|
16
|
+
export function reportSourceDiagnostic(source: any, position: number, reason: string, message: string,
|
|
17
|
+
reporter?: SourceDiagnosticReporter): void {
|
|
18
|
+
if (!reporter) return;
|
|
19
|
+
const location = source.getLineAndCharacterOfPosition(position);
|
|
20
|
+
reporter({ fileName: source.fileName, line: location.line + 1, column: location.character + 1, reason, message });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse declarative child blocks without treating enum members as components. */
|
|
24
|
+
export function parseApplicationSource(ts: any, id: string, code: string, preliminary?: any): any {
|
|
25
|
+
const source = preliminary ?? ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
|
|
26
|
+
ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
|
|
27
|
+
if (!id.endsWith('.ets') || typeof ts.isStructDeclaration !== 'function' ||
|
|
28
|
+
typeof ts.isEtsComponentExpression !== 'function') return source;
|
|
29
|
+
const names = new Set<string>();
|
|
30
|
+
const visit = (node: any): void => {
|
|
31
|
+
if ((ts.isCallExpression(node) || ts.isEtsComponentExpression(node)) && ts.isIdentifier(node.expression)) {
|
|
32
|
+
names.add(node.expression.text);
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(source);
|
|
37
|
+
const components = Array.from(names).filter(name => !['ForEach', 'LazyForEach', 'Repeat'].includes(name));
|
|
38
|
+
return ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS, { ets: {
|
|
39
|
+
components,
|
|
40
|
+
render: { method: ['build', 'pageTransition'], decorator: ['Builder', 'LocalBuilder'] },
|
|
41
|
+
extend: { decorator: ['Extend', 'AnimatableExtend'], components: components.map(name =>
|
|
42
|
+
({ name, type: `${name}Attribute`, instance: `${name}Instance` })) },
|
|
43
|
+
styles: { decorator: 'Styles', component: { name: 'Common', type: 'T', instance: 'CommonInstance' }, property: 'stateStyles' },
|
|
44
|
+
concurrent: { decorator: 'Concurrent' },
|
|
45
|
+
customComponent: 'CustomComponent',
|
|
46
|
+
syntaxComponents: { paramsUICallback: ['ForEach', 'LazyForEach'],
|
|
47
|
+
attrUICallback: [{ name: 'Repeat', attributes: ['each', 'template'] }] }
|
|
48
|
+
} });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function reportSourceParseError(ts: any, source: any, reporter?: SourceDiagnosticReporter): boolean {
|
|
52
|
+
if (source.parseDiagnostics.length === 0) return false;
|
|
53
|
+
const diagnostic = source.parseDiagnostics[0];
|
|
54
|
+
const remaining = source.parseDiagnostics.length - 1;
|
|
55
|
+
reportSourceDiagnostic(source, diagnostic.start ?? 0, 'parse-error',
|
|
56
|
+
`TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}${
|
|
57
|
+
remaining > 0 ? ` (${remaining} additional parse errors)` : ''}`, reporter);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function objectProperty(ts: any, object: any, name: string): any | undefined {
|
|
62
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return undefined;
|
|
63
|
+
const matches = object.properties.filter((item: any) => ts.isPropertyAssignment(item) &&
|
|
64
|
+
(ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === name);
|
|
65
|
+
return matches.length > 0 ? matches[matches.length - 1].initializer : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read JSON5 as data syntax, without evaluating package contents. */
|
|
69
|
+
export function hasRuntimeDependency(ts: any, id: string, packageName: string, readSource?: SourceReader): boolean {
|
|
70
|
+
const normalized = id.replace(/\\/g, '/');
|
|
71
|
+
const sourceIndex = normalized.lastIndexOf('/src/main/ets/');
|
|
72
|
+
if (sourceIndex < 0) return false;
|
|
73
|
+
const packagePath = normalized.slice(0, sourceIndex) + '/oh-package.json5';
|
|
74
|
+
try {
|
|
75
|
+
const nodeRequire = require.main && typeof require.main.require === 'function'
|
|
76
|
+
? require.main.require.bind(require.main) : require;
|
|
77
|
+
const code = readSource ? readSource(packagePath) : nodeRequire('fs').readFileSync(packagePath, 'utf8');
|
|
78
|
+
if (typeof code !== 'string') return false;
|
|
79
|
+
const parsed = ts.createSourceFile(packagePath, `const __package = ${code}`,
|
|
80
|
+
ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
81
|
+
if (parsed.parseDiagnostics.length > 0 || parsed.statements.length !== 1 ||
|
|
82
|
+
!ts.isVariableStatement(parsed.statements[0])) return false;
|
|
83
|
+
const root = parsed.statements[0].declarationList.declarations[0].initializer;
|
|
84
|
+
const dependency = objectProperty(ts, objectProperty(ts, root, 'dependencies'), packageName);
|
|
85
|
+
// Runtime imports require an explicit dependency in the owning module.
|
|
86
|
+
// Do not inherit the entry/root module's dependencies or match comments/devDependencies.
|
|
87
|
+
return !!dependency && ts.isStringLiteral(dependency) && dependency.text.trim().length > 0;
|
|
88
|
+
} catch (_) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|