@cloudcare/hvigor-ohos-plugin 0.1.0 → 0.1.2-alpha.1

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.
@@ -0,0 +1,363 @@
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 } from './ft-transform-context';
12
+ import { allocateName as selectUniqueName } from './ft-generated-names';
13
+
14
+ type TypeScriptApi = any;
15
+
16
+ interface SourceRange {
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ interface MethodRange {
22
+ nameStart: number;
23
+ memberStart: number;
24
+ node: any;
25
+ bodyStart: number;
26
+ bodyEnd: number;
27
+ }
28
+
29
+ interface RootExpression {
30
+ end: number;
31
+ idArgument?: SourceRange;
32
+ hasAreaChange: boolean;
33
+ hasTouch: boolean;
34
+ touchArgument?: SourceRange;
35
+ }
36
+
37
+ interface EntryStruct {
38
+ bodyEnd: number;
39
+ methods: Map<string, MethodRange>;
40
+ }
41
+
42
+ interface Injection {
43
+ position: number;
44
+ content: string;
45
+ }
46
+
47
+ const REPLAY_BRIDGE_IMPORT = '@guancecloud/ft_session_replay';
48
+ const REPLAY_ROOT_TRANSFORM_MARKER = '/* guance-session-replay-root-transform:instrumented */';
49
+
50
+ function lineIndentation(code: string, position: number): string {
51
+ const lineStart = code.lastIndexOf('\n', position) + 1;
52
+ const match = code.slice(lineStart, position).match(/^\s*/);
53
+ return match === null ? '' : match[0];
54
+ }
55
+
56
+ function collectIdentifiers(code: string): Set<string> {
57
+ const identifiers = new Set<string>();
58
+ const expression = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g;
59
+ let match: RegExpExecArray | null;
60
+ while ((match = expression.exec(code)) !== null) {
61
+ identifiers.add(match[0]);
62
+ }
63
+ return identifiers;
64
+ }
65
+
66
+ /** A stable opaque source key. Do not emit customers' project paths into UI IDs. */
67
+ function createSourceKey(id: string): string {
68
+ let hash = 0x811c9dc5;
69
+ const normalized = id.replace(/\\/g, '/').toLowerCase();
70
+ for (let index = 0; index < normalized.length; index++) {
71
+ hash ^= normalized.charCodeAt(index);
72
+ hash = Math.imul(hash, 0x01000193);
73
+ }
74
+ return (hash >>> 0).toString(36);
75
+ }
76
+
77
+ function findImportInsertionOffset(ts: TypeScriptApi, id: string, code: string): number {
78
+ const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
79
+ ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
80
+ let offset = 0;
81
+ sourceFile.statements.forEach((statement: any): void => {
82
+ if (ts.isImportDeclaration(statement)) {
83
+ offset = statement.end;
84
+ }
85
+ });
86
+ return offset;
87
+ }
88
+
89
+ /** Structs, decorators and members must come from the ArkTS parser, never text matches. */
90
+ function collectEntryStructs(ts: TypeScriptApi, source: any): EntryStruct[] {
91
+ const entries: EntryStruct[] = [];
92
+ source.statements.forEach((node: any) => {
93
+ if (!ts.isStructDeclaration(node)) return;
94
+ const decorators = ts.getDecorators ? ts.getDecorators(node) || [] : node.decorators || [];
95
+ const isEntry = decorators.some((decorator: any) => {
96
+ const expression = ts.isCallExpression(decorator.expression)
97
+ ? decorator.expression.expression : decorator.expression;
98
+ return ts.isIdentifier(expression) && expression.text === 'Entry';
99
+ });
100
+ if (!isEntry) return;
101
+ const methods = new Map<string, MethodRange>();
102
+ node.members.forEach((member: any) => {
103
+ if (ts.isMethodDeclaration(member) && member.body && ts.isIdentifier(member.name)) {
104
+ methods.set(member.name.text, { nameStart: member.name.getStart(source),
105
+ memberStart: member.getStart(source), node: member,
106
+ bodyStart: member.body.getStart(source), bodyEnd: member.body.end - 1 });
107
+ }
108
+ });
109
+ entries.push({ bodyEnd: node.end - 1, methods });
110
+ });
111
+ return entries;
112
+ }
113
+
114
+ function componentChain(ts: TypeScriptApi, expression: any): { base: any; modifiers: any[] } {
115
+ const modifiers: any[] = [];
116
+ let base = expression;
117
+ while (ts.isCallExpression(base) && ts.isPropertyAccessExpression(base.expression)) {
118
+ modifiers.unshift(base);
119
+ base = base.expression.expression;
120
+ }
121
+ return { base, modifiers };
122
+ }
123
+
124
+ function argumentRange(source: any, call: any): SourceRange {
125
+ // Include comments and newlines before the closing parenthesis when reusing an argument.
126
+ return { start: call.arguments.pos, end: call.getLastToken(source).getStart(source) };
127
+ }
128
+
129
+ function findRootExpression(ts: TypeScriptApi, source: any, code: string,
130
+ build: MethodRange): RootExpression | null {
131
+ const first = build.node.body.statements[0];
132
+ if (!first || (!ts.isExpressionStatement(first) && !ts.isReturnStatement(first)) || !first.expression) return null;
133
+ const { base, modifiers } = componentChain(ts, first.expression);
134
+ if (!ts.isEtsComponentExpression(base) || !base.body) return null;
135
+ let idArgument: SourceRange | undefined;
136
+ let hasAreaChange = false;
137
+ let hasTouch = false;
138
+ let touchArgument: SourceRange | undefined;
139
+ modifiers.forEach((call: any) => {
140
+ const name = call.expression.name.text;
141
+ const range = argumentRange(source, call);
142
+ if (name === 'id' && call.arguments.length === 1) {
143
+ const argument = call.arguments[0];
144
+ idArgument = { start: argument.getStart(source), end: argument.end };
145
+ }
146
+ if (name === 'onAreaChange') hasAreaChange = true;
147
+ if (name === 'onTouch') {
148
+ hasTouch = true;
149
+ if (call.arguments.length === 1) touchArgument = range;
150
+ }
151
+ });
152
+ return { end: first.expression.end, idArgument, hasAreaChange, hasTouch, touchArgument };
153
+ }
154
+
155
+ function applyInjections(code: string, injections: Injection[]): string {
156
+ const grouped = new Map<number, string[]>();
157
+ injections.forEach((injection: Injection): void => {
158
+ const values = grouped.get(injection.position) || [];
159
+ values.push(injection.content);
160
+ grouped.set(injection.position, values);
161
+ });
162
+ const positions = Array.from(grouped.keys()).sort((left: number, right: number): number => right - left);
163
+ let transformed = code;
164
+ positions.forEach((position: number): void => {
165
+ transformed = transformed.slice(0, position) + (grouped.get(position) || []).join('') + transformed.slice(position);
166
+ });
167
+ return transformed;
168
+ }
169
+
170
+ function addTransitionInjections(ts: TypeScriptApi, source: any, code: string, injections: Injection[],
171
+ entry: EntryStruct, bridgeName: string, tokenField: string, rootExpression: string): void {
172
+ const method = entry.methods.get('pageTransition');
173
+ if (!method) {
174
+ const indentation = `${lineIndentation(code, entry.bodyEnd)} `;
175
+ injections.push({ position: entry.bodyEnd, content: `\n${indentation}pageTransition(): void {\n` +
176
+ `${indentation} if (${bridgeName}.defaultTransitionStarted(this.${tokenField})) {}\n${indentation}}\n` });
177
+ return;
178
+ }
179
+ let nextSpec = 0;
180
+ method.node.body.statements.forEach((statement: any) => {
181
+ if (!ts.isExpressionStatement(statement)) return;
182
+ const { base, modifiers } = componentChain(ts, statement.expression);
183
+ if ((!ts.isCallExpression(base) && !ts.isEtsComponentExpression(base)) || !ts.isIdentifier(base.expression)) return;
184
+ const name = base.expression.text;
185
+ if (name !== 'PageTransitionEnter' && name !== 'PageTransitionExit') return;
186
+ const entering = name === 'PageTransitionEnter';
187
+ const callbackName = entering ? 'onEnter' : 'onExit';
188
+ const spec = JSON.stringify(`transition-${nextSpec++}`);
189
+ const prefix = `this.${tokenField}, ${rootExpression}, ${spec}, ${entering}`;
190
+ let callback: SourceRange | undefined;
191
+ modifiers.forEach((call: any) => {
192
+ const modifierName = call.expression.name.text;
193
+ const range = argumentRange(source, call);
194
+ if ((modifierName === 'slide' || modifierName === 'opacity') && call.arguments.length === 1) {
195
+ const hook = modifierName === 'slide' ? 'transitionSlide' : 'transitionOpacity';
196
+ injections.push({ position: range.start,
197
+ content: `${bridgeName}.${hook}(this.${tokenField}, ${spec}, ` });
198
+ injections.push({ position: range.end, content: ')' });
199
+ } else if (modifierName === callbackName && call.arguments.length === 1) callback = range;
200
+ });
201
+ if (callback) {
202
+ injections.push({ position: callback.start, content: `${bridgeName}.wrapTransition(${prefix}, ` });
203
+ injections.push({ position: callback.end, content: ')' });
204
+ } else {
205
+ injections.push({ position: statement.expression.end,
206
+ content: `.${callbackName}((type: RouteType, progress: number) => { ${bridgeName}.transitionProgress(${prefix}, progress); })` });
207
+ }
208
+ });
209
+ }
210
+
211
+ function lifecycleCall(bridgeName: string, tokenField: string, method: 'pageShown' | 'pageHidden' | 'pageDisposed',
212
+ rootExpression: string, observeDefaultTransition: boolean): string {
213
+ if (method === 'pageShown') {
214
+ return `${bridgeName}.pageShown(this.getUIContext(), this.${tokenField}, ${rootExpression}, vp2px(1), ${observeDefaultTransition});`;
215
+ }
216
+ return `${bridgeName}.${method}(this.${tokenField});`;
217
+ }
218
+
219
+ function addLifecycleInjection(
220
+ code: string,
221
+ injections: Injection[],
222
+ struct: EntryStruct,
223
+ buildMethod: MethodRange,
224
+ bridgeName: string,
225
+ tokenField: string,
226
+ methodName: 'onPageShow' | 'onPageHide' | 'aboutToDisappear',
227
+ runtimeMethod: 'pageShown' | 'pageHidden' | 'pageDisposed',
228
+ rootExpression: string,
229
+ visibleField?: string
230
+ ): void {
231
+ const existing = struct.methods.get(methodName);
232
+ const rawCall = lifecycleCall(bridgeName, tokenField, runtimeMethod, rootExpression,
233
+ !struct.methods.has('pageTransition'));
234
+ const call = !visibleField ? rawCall : methodName === 'onPageShow'
235
+ ? `this.${visibleField} = true; if (${rootExpression}.length > 0) { ${rawCall} }`
236
+ : `this.${visibleField} = false; ${rawCall}`;
237
+ if (existing) {
238
+ const indentation = `${lineIndentation(code, existing.nameStart)} `;
239
+ injections.push({ position: existing.bodyStart + 1, content: `\n${indentation}${call}` });
240
+ return;
241
+ }
242
+ const indentation = lineIndentation(code, buildMethod.nameStart);
243
+ injections.push({
244
+ position: buildMethod.memberStart,
245
+ content: `\n${indentation}${methodName}(): void {\n${indentation} ${call}\n${indentation}}\n\n`
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Adds an opaque root ID and page lifecycle registration to every @Entry page.
251
+ * ArkTS nodes locate real decorators, struct members and component expressions.
252
+ */
253
+ export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader): string {
254
+ if (!isApplicationArkTsSource(id) || code.includes(REPLAY_ROOT_TRANSFORM_MARKER) ||
255
+ !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
256
+ !hasRuntimeDependency(ts, id, REPLAY_BRIDGE_IMPORT, readSource)) {
257
+ return code;
258
+ }
259
+
260
+ if (typeof ts.isStructDeclaration !== 'function' || typeof ts.isEtsComponentExpression !== 'function') return code;
261
+ // The parser needs component names to recognize declarative child blocks.
262
+ // This superset configures parsing only; eligibility is decided by AST nodes.
263
+ const components = Array.from(collectIdentifiers(code));
264
+ const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS,
265
+ { ets: {
266
+ components,
267
+ render: { method: ['build', 'pageTransition'], decorator: ['Builder', 'LocalBuilder'] },
268
+ extend: { decorator: ['Extend', 'AnimatableExtend'], components: components.map(name =>
269
+ ({ name, type: `${name}Attribute`, instance: `${name}Instance` })) },
270
+ styles: { decorator: 'Styles', component: { name: 'Common', type: 'T', instance: 'CommonInstance' }, property: 'stateStyles' },
271
+ concurrent: { decorator: 'Concurrent' },
272
+ customComponent: 'CustomComponent',
273
+ syntaxComponents: { paramsUICallback: ['ForEach', 'LazyForEach'], attrUICallback: [{ name: 'Repeat', attributes: ['each', 'template'] }] }
274
+ } });
275
+ if (source.parseDiagnostics.length > 0) return code;
276
+ const entries = collectEntryStructs(ts, source);
277
+ if (entries.length === 0) {
278
+ return code;
279
+ }
280
+
281
+ const identifiers = collectIdentifiers(code);
282
+ const bridgeName = selectUniqueName(identifiers, '__ftSessionReplayAutoRootBridge');
283
+ const tokenField = selectUniqueName(identifiers, '__ftSessionReplayRootToken');
284
+ const sourceKey = createSourceKey(id);
285
+ const injections: Injection[] = [];
286
+
287
+ entries.forEach((entry: EntryStruct): void => {
288
+ const buildMethod = entry.methods.get('build');
289
+ if (!buildMethod) {
290
+ return;
291
+ }
292
+ const root = findRootExpression(ts, source, code, buildMethod);
293
+ if (root === null) {
294
+ return;
295
+ }
296
+ const indentation = lineIndentation(code, buildMethod.nameStart);
297
+ injections.push({
298
+ position: buildMethod.memberStart,
299
+ content: `\n${indentation}private ${tokenField}: string = ${bridgeName}.createRootToken(${JSON.stringify(sourceKey)});\n\n`
300
+ });
301
+
302
+ let rootExpression = `this.${tokenField}`;
303
+ let visibleField: string | undefined;
304
+ if (root.idArgument) {
305
+ const idField = selectUniqueName(identifiers, '__ftSessionReplayRootId');
306
+ visibleField = selectUniqueName(identifiers, '__ftSessionReplayVisible');
307
+ const capture = selectUniqueName(identifiers, '__ftSessionReplayCaptureId');
308
+ const value = selectUniqueName(identifiers, '__ftSessionReplayIdValue');
309
+ rootExpression = `this.${idField}`;
310
+ const shown = lifecycleCall(bridgeName, tokenField, 'pageShown', rootExpression,
311
+ !entry.methods.has('pageTransition'));
312
+ injections.push({ position: buildMethod.memberStart,
313
+ content: `\n${indentation}private ${idField}: string = '';\n` +
314
+ `${indentation}private ${visibleField}: boolean = false;\n` +
315
+ `${indentation}private ${capture}(${value}: string): string {\n` +
316
+ `${indentation} const changed: boolean = this.${idField} !== ${value};\n` +
317
+ `${indentation} this.${idField} = ${value};\n` +
318
+ `${indentation} if (changed && this.${visibleField} && ${value}.length > 0) { ${shown} }\n` +
319
+ `${indentation} return ${value};\n${indentation}}\n` });
320
+ injections.push({ position: root.idArgument.start, content: `this.${capture}(` });
321
+ injections.push({ position: root.idArgument.end, content: ')' });
322
+ } else {
323
+ injections.push({ position: root.end, content: `.id(${rootExpression})` });
324
+ }
325
+ // Root area changes are ArkUI's public layout/draw boundary. Emit a
326
+ // coalesced Replay signal there instead of installing a fixed full-tree
327
+ // polling loop. Do not add a second modifier when an app already owns one.
328
+ if (!root.hasAreaChange) {
329
+ injections.push({
330
+ position: root.end,
331
+ content: `.onAreaChange(() => { ${bridgeName}.pageChanged(this.${tokenField}); })`
332
+ });
333
+ }
334
+ // Preserve application callbacks and their event propagation behavior.
335
+ // The bridge forwards real pointer coordinates independently of drawing.
336
+ if (!root.hasTouch) {
337
+ injections.push({
338
+ position: root.end,
339
+ content: `.onTouch((event: TouchEvent) => { ${bridgeName}.pageTouch(this.${tokenField}, event); })`
340
+ });
341
+ } else if (root.touchArgument !== undefined) {
342
+ injections.push({ position: root.touchArgument.start,
343
+ content: `${bridgeName}.wrapTouch(this.${tokenField}, ` });
344
+ injections.push({ position: root.touchArgument.end, content: ')' });
345
+ }
346
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
347
+ 'onPageShow', 'pageShown', rootExpression, visibleField);
348
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
349
+ 'onPageHide', 'pageHidden', rootExpression, visibleField);
350
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
351
+ 'aboutToDisappear', 'pageDisposed', rootExpression, visibleField);
352
+ addTransitionInjections(ts, source, code, injections, entry, bridgeName, tokenField, rootExpression);
353
+ });
354
+
355
+ if (injections.length === 0) {
356
+ return code;
357
+ }
358
+ const importOffset = findImportInsertionOffset(ts, id, code);
359
+ const importStatement = `\n${REPLAY_ROOT_TRANSFORM_MARKER}\n` +
360
+ `import { FTSessionReplayAutoRootBridge as ${bridgeName} } from '${REPLAY_BRIDGE_IMPORT}';\n`;
361
+ injections.push({ position: importOffset, content: importStatement });
362
+ return applyInjections(code, injections);
363
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Guance compiler source-transform host.
3
+ *
4
+ * Hvigor reads ArkTS source while creating its type-checking program, before
5
+ * normal Rollup transforms run. This host owns that single read hook and
6
+ * composes feature transforms in a deterministic order. Feature modules must
7
+ * only transform source; they must not patch the file system themselves.
8
+ */
9
+
10
+ import {
11
+ isApplicationArkTsSource,
12
+ transformConsoleLogSource
13
+ } from './ft-console-log-transform';
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
+
18
+ declare const require: {
19
+ main: {
20
+ require(moduleName: string): any;
21
+ };
22
+ };
23
+
24
+ type TypeScriptApi = any;
25
+ type SourceReader = (fileName: string) => string | undefined;
26
+ type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string, readSource?: SourceReader) => string;
27
+
28
+ interface CompilePluginContext {
29
+ share: {
30
+ projectConfig: {
31
+ etsLoaderPath: string;
32
+ };
33
+ };
34
+ }
35
+
36
+ interface SourceTransformDefinition {
37
+ id: string;
38
+ transform: SourceTransform;
39
+ }
40
+
41
+ interface SharedSourceTransformRegistry {
42
+ originalReadFile: (fileName: string, options?: any) => any;
43
+ buffer: any;
44
+ transforms: Map<string, (fileName: string, code: string) => string>;
45
+ owners: Map<object, Map<string, (fileName: string, code: string) => string>>;
46
+ }
47
+
48
+ const SOURCE_TRANSFORM_REGISTRY_KEY = '__guanceHvigorSourceTransformRegistry';
49
+ const SOURCE_TRANSFORMS: SourceTransformDefinition[] = [
50
+ { id: 'guance-console-log-transform', transform: transformConsoleLogSource },
51
+ { id: 'guance-cold-launch-transform', transform: transformColdLaunchSource },
52
+ { id: 'guance-session-replay-root-transform', transform: transformSessionReplaySource },
53
+ { id: 'guance-web-replay-transform', transform: transformWebReplaySource }
54
+ ];
55
+
56
+ const registrations: Array<{ fileSystem: any; owner: object; context: CompilePluginContext }> = [];
57
+
58
+ function refreshTransforms(registry: SharedSourceTransformRegistry): void {
59
+ registry.transforms.clear();
60
+ registry.owners.forEach(transforms => transforms.forEach((transform, id) => registry.transforms.set(id, transform)));
61
+ }
62
+
63
+ function installSourceReader(context: CompilePluginContext): void {
64
+ const typeScriptPath = `${context.share.projectConfig.etsLoaderPath}/node_modules/typescript`;
65
+ const ts = require.main.require(typeScriptPath);
66
+ const fileSystem = require.main.require('fs');
67
+ let registry = fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] as SharedSourceTransformRegistry | undefined;
68
+ if (!registry) {
69
+ registry = {
70
+ originalReadFile: fileSystem.readFileSync as (fileName: string, options?: any) => any,
71
+ buffer: require.main.require('buffer'),
72
+ transforms: new Map<string, (fileName: string, code: string) => string>(),
73
+ owners: new Map()
74
+ };
75
+ fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] = registry;
76
+ fileSystem.readFileSync = (fileName: string, options?: any): any => {
77
+ const content = registry!.originalReadFile(fileName, options);
78
+ if (typeof fileName !== 'string' || !isApplicationArkTsSource(fileName)) {
79
+ return content;
80
+ }
81
+ let transformedCode = typeof content === 'string' ? content : content.toString();
82
+ registry!.transforms.forEach((transform: (id: string, code: string) => string) => {
83
+ transformedCode = transform(fileName, transformedCode);
84
+ });
85
+ return typeof content === 'string' ? transformedCode : registry!.buffer.Buffer.from(transformedCode);
86
+ };
87
+ }
88
+
89
+ const readSource: SourceReader = (fileName: string): string | undefined => {
90
+ try {
91
+ return registry!.originalReadFile(fileName, 'utf8').toString();
92
+ } catch (_) {
93
+ return undefined;
94
+ }
95
+ };
96
+ const owner = {};
97
+ const transforms = new Map<string, (fileName: string, code: string) => string>();
98
+ SOURCE_TRANSFORMS.forEach((definition: SourceTransformDefinition) => {
99
+ transforms.set(definition.id, (fileName: string, code: string) =>
100
+ definition.transform(ts, fileName, code, readSource));
101
+ });
102
+ registry.owners.set(owner, transforms);
103
+ refreshTransforms(registry);
104
+ registrations.push({ fileSystem, owner, context });
105
+ }
106
+
107
+ function restoreSourceReader(context: CompilePluginContext): void {
108
+ let index = registrations.length - 1;
109
+ for (let candidate = registrations.length - 1; candidate >= 0; candidate--) {
110
+ if (registrations[candidate].context === context) { index = candidate; break; }
111
+ }
112
+ const registration = index < 0 ? undefined : registrations.splice(index, 1)[0];
113
+ if (!registration) return;
114
+ const { fileSystem, owner } = registration;
115
+ const registry = fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] as SharedSourceTransformRegistry | undefined;
116
+ if (registry) {
117
+ registry.owners.delete(owner);
118
+ refreshTransforms(registry);
119
+ if (registry.owners.size === 0) {
120
+ fileSystem.readFileSync = registry.originalReadFile;
121
+ delete fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY];
122
+ }
123
+ }
124
+ }
125
+
126
+ export default {
127
+ name: 'guance-source-transform-host',
128
+ order: 'first',
129
+ buildStart(this: CompilePluginContext): void {
130
+ installSourceReader(this);
131
+ },
132
+ buildEnd(this: CompilePluginContext): void {
133
+ restoreSourceReader(this);
134
+ }
135
+ };
@@ -0,0 +1,36 @@
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 function objectProperty(ts: any, object: any, name: string): any | undefined {
7
+ if (!object || !ts.isObjectLiteralExpression(object)) return undefined;
8
+ const matches = object.properties.filter((item: any) => ts.isPropertyAssignment(item) &&
9
+ (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === name);
10
+ return matches.length > 0 ? matches[matches.length - 1].initializer : undefined;
11
+ }
12
+
13
+ /** Read JSON5 as data syntax, without evaluating package contents. */
14
+ export function hasRuntimeDependency(ts: any, id: string, packageName: string, readSource?: SourceReader): boolean {
15
+ const normalized = id.replace(/\\/g, '/');
16
+ const sourceIndex = normalized.lastIndexOf('/src/main/ets/');
17
+ if (sourceIndex < 0) return false;
18
+ const packagePath = normalized.slice(0, sourceIndex) + '/oh-package.json5';
19
+ try {
20
+ const nodeRequire = require.main && typeof require.main.require === 'function'
21
+ ? require.main.require.bind(require.main) : require;
22
+ const code = readSource ? readSource(packagePath) : nodeRequire('fs').readFileSync(packagePath, 'utf8');
23
+ if (typeof code !== 'string') return false;
24
+ const parsed = ts.createSourceFile(packagePath, `const __package = ${code}`,
25
+ ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
26
+ if (parsed.parseDiagnostics.length > 0 || parsed.statements.length !== 1 ||
27
+ !ts.isVariableStatement(parsed.statements[0])) return false;
28
+ const root = parsed.statements[0].declarationList.declarations[0].initializer;
29
+ const dependency = objectProperty(ts, objectProperty(ts, root, 'dependencies'), packageName);
30
+ // Runtime imports require an explicit dependency in the owning module.
31
+ // Do not inherit the entry/root module's dependencies or match comments/devDependencies.
32
+ return !!dependency && ts.isStringLiteral(dependency) && dependency.text.trim().length > 0;
33
+ } catch (_) {
34
+ return false;
35
+ }
36
+ }
@@ -0,0 +1,51 @@
1
+ /** Associate every native Web with its controller without replacing application IDs or callbacks. */
2
+ import { isApplicationArkTsSource } from './ft-console-log-transform';
3
+ import { hasRuntimeDependency, SourceReader } from './ft-transform-context';
4
+ import { createLexicalResolver } from './ft-lexical-scope';
5
+ import { allocateName, collectIdentifierNames } from './ft-generated-names';
6
+
7
+ type TypeScriptApi = any;
8
+ interface Injection { position: number; content: string; }
9
+
10
+ const MARKER = '/* guance-web-replay-transform:instrumented */';
11
+ const SDK_IMPORT = '@guancecloud/ft_sdk/Index';
12
+
13
+ export function transformWebReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader): string {
14
+ if (!isApplicationArkTsSource(id) || code.includes(MARKER) ||
15
+ !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
16
+ !hasRuntimeDependency(ts, id, '@guancecloud/ft_session_replay', readSource)) return code;
17
+
18
+ const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
19
+ const identifiers = collectIdentifierNames(ts, source);
20
+ const constructors: any[] = [];
21
+ const resolve = createLexicalResolver(ts, source);
22
+ let importEnd = 0;
23
+ const visit = (node: any): void => {
24
+ if (ts.isImportDeclaration(node)) importEnd = Math.max(importEnd, node.end);
25
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Web' &&
26
+ node.arguments.length === 1 && !ts.isSpreadElement(node.arguments[0]) &&
27
+ !resolve(node.expression, 'Web')) constructors.push(node);
28
+ ts.forEachChild(node, visit);
29
+ };
30
+ visit(source);
31
+ if (constructors.length === 0) return code;
32
+
33
+ const bridge = allocateName(identifiers, '__ftWebReplayBridge');
34
+ const injections: Injection[] = [];
35
+ for (const constructor of constructors) {
36
+ const options = constructor.arguments[0];
37
+ // Keep the pair adjacent, before any app modifier expression can evaluate another Web.
38
+ injections.push({ position: options.getStart(source), content: `${bridge}.prepare(` });
39
+ injections.push({ position: options.end, content: ')' });
40
+ injections.push({ position: constructor.end,
41
+ content: `.customProperty(${bridge}.PROPERTY, ${bridge}.consumeBindingId())` });
42
+ }
43
+ injections.push({ position: importEnd,
44
+ content: `\n${MARKER}\nimport { FTWebViewReplayBridge as ${bridge} } from '${SDK_IMPORT}';\n` });
45
+ injections.sort((left, right) => right.position - left.position);
46
+ let result = code;
47
+ for (const injection of injections) {
48
+ result = result.slice(0, injection.position) + injection.content + result.slice(injection.position);
49
+ }
50
+ return result;
51
+ }
package/index.js CHANGED
@@ -4,21 +4,21 @@ const path = require('path');
4
4
  const { OhosPluginId } = require('@ohos/hvigor-ohos-plugin');
5
5
 
6
6
  /**
7
- * Returns the HAP-level Hvigor plugin used to instrument console/HiLog calls
8
- * and cold-launch lifecycle boundaries before ArkTS compilation.
7
+ * Returns the HAP-level Hvigor plugin used to instrument console/HiLog calls,
8
+ * cold-launch lifecycle boundaries, Session Replay page roots and Web bindings before
9
+ * ArkTS compilation.
9
10
  *
10
11
  * This package intentionally contains build tooling only. Applications still
11
- * need @guancecloud/ft_sdk at runtime to receive the instrumented log calls.
12
+ * need their matching runtime HARs to receive instrumented calls. The Replay
13
+ * transform self-disables when @guancecloud/ft_session_replay is absent.
12
14
  */
13
15
  function ftConsoleLogPlugin() {
14
16
  return {
15
17
  pluginId: 'guance-console-log-compiler',
16
18
  apply(node) {
17
19
  const hapContext = node.getContext(OhosPluginId.OHOS_HAP_PLUGIN);
18
- const consoleLogTransformPath = path.join(__dirname, 'hvigorCompile', 'ft-console-log-transform.ts');
19
- const coldLaunchTransformPath = path.join(__dirname, 'hvigorCompile', 'ft-cold-launch-transform.ts');
20
- hapContext.loadCompilePlugin(consoleLogTransformPath);
21
- hapContext.loadCompilePlugin(coldLaunchTransformPath);
20
+ const sourceTransformHostPath = path.join(__dirname, 'hvigorCompile', 'ft-source-transform-host.ts');
21
+ hapContext.loadCompilePlugin(sourceTransformHostPath);
22
22
  }
23
23
  };
24
24
  }
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "@cloudcare/hvigor-ohos-plugin",
3
- "version": "0.1.0",
4
- "description": "Guance HarmonyOS Hvigor plugin for automatic console, HiLog, and cold-launch collection.",
3
+ "version": "0.1.2-alpha.1",
4
+ "description": "Guance HarmonyOS Hvigor plugin for console, cold-launch, Session Replay page roots, and Web controller binding.",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
8
+ "hvigorCompile/ft-source-transform-host.ts",
9
+ "hvigorCompile/ft-transform-context.ts",
10
+ "hvigorCompile/ft-lexical-scope.ts",
11
+ "hvigorCompile/ft-generated-names.ts",
8
12
  "hvigorCompile/ft-console-log-transform.ts",
9
13
  "hvigorCompile/ft-cold-launch-transform.ts",
14
+ "hvigorCompile/ft-session-replay-transform.ts",
15
+ "hvigorCompile/ft-web-replay-transform.ts",
10
16
  "README.md",
11
17
  "CHANGELOG.md",
12
18
  "LICENSE"
@@ -26,11 +32,15 @@
26
32
  "node": ">=18"
27
33
  },
28
34
  "peerDependencies": {
29
- "@guancecloud/ft_sdk": ">=0.1.17"
35
+ "@guancecloud/ft_sdk": ">=0.1.17",
36
+ "@guancecloud/ft_session_replay": ">=0.1.0-alpha01"
30
37
  },
31
38
  "peerDependenciesMeta": {
32
39
  "@guancecloud/ft_sdk": {
33
40
  "optional": true
41
+ },
42
+ "@guancecloud/ft_session_replay": {
43
+ "optional": true
34
44
  }
35
45
  }
36
46
  }