@pagepocket/capture-http-lighterceptor-unit 0.8.0
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/dist/capture-http-lighterceptor-plugin.d.ts +9 -0
- package/dist/capture-http-lighterceptor-plugin.js +140 -0
- package/dist/capture-http-lighterceptor-unit.d.ts +13 -0
- package/dist/capture-http-lighterceptor-unit.js +126 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/lighterceptor-adapter.d.ts +24 -0
- package/dist/internal/lighterceptor-adapter.js +200 -0
- package/dist/internal/trigger-actions.d.ts +2 -0
- package/dist/internal/trigger-actions.js +35 -0
- package/dist/internal/utils/base64.d.ts +1 -0
- package/dist/internal/utils/base64.js +12 -0
- package/dist/internal/utils/headers.d.ts +1 -0
- package/dist/internal/utils/headers.js +4 -0
- package/dist/internal/utils/resource-type.d.ts +2 -0
- package/dist/internal/utils/resource-type.js +25 -0
- package/package.json +24 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PagePocketContext, type PagePocketPlugin } from "@pagepocket/lib";
|
|
2
|
+
import { type LighterceptorAdapterOptions } from "./internal/lighterceptor-adapter.js";
|
|
3
|
+
export type CaptureHttpLighterceptorPluginOptions = LighterceptorAdapterOptions;
|
|
4
|
+
export declare class CaptureHttpLighterceptorPlugin implements PagePocketPlugin {
|
|
5
|
+
readonly name = "plugin:capture-http-lighterceptor";
|
|
6
|
+
private adapterOptions;
|
|
7
|
+
constructor(options?: CaptureHttpLighterceptorPluginOptions);
|
|
8
|
+
apply(ctx: PagePocketContext): void;
|
|
9
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createMemoryContentStore } from "@pagepocket/lib";
|
|
2
|
+
import { InflightTracker, networkIdle, normalizeCompletion, timeout } from "@pagepocket/lib";
|
|
3
|
+
import { LighterceptorAdapter } from "./internal/lighterceptor-adapter.js";
|
|
4
|
+
const headersRecordToList = (headers) => {
|
|
5
|
+
if (!headers)
|
|
6
|
+
return [];
|
|
7
|
+
return Object.keys(headers).map((name) => ({ name, value: headers[name] }));
|
|
8
|
+
};
|
|
9
|
+
export class CaptureHttpLighterceptorPlugin {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.name = "plugin:capture-http-lighterceptor";
|
|
12
|
+
this.adapterOptions = options ?? {};
|
|
13
|
+
}
|
|
14
|
+
apply(ctx) {
|
|
15
|
+
const contentStore = createMemoryContentStore("capture-http-lighterceptor");
|
|
16
|
+
const events = [];
|
|
17
|
+
const capabilities = {
|
|
18
|
+
requestHeaders: "approx",
|
|
19
|
+
responseHeaders: "approx",
|
|
20
|
+
requestBodies: false,
|
|
21
|
+
responseBodies: "decoded",
|
|
22
|
+
httpVersion: false,
|
|
23
|
+
remoteIp: false,
|
|
24
|
+
headerOrderPreserved: false
|
|
25
|
+
};
|
|
26
|
+
ctx.capture = {
|
|
27
|
+
events,
|
|
28
|
+
contentStore,
|
|
29
|
+
capabilities
|
|
30
|
+
};
|
|
31
|
+
const inflightTracker = new InflightTracker();
|
|
32
|
+
const handleNetworkEvent = async (event) => {
|
|
33
|
+
inflightTracker.handleEvent(event);
|
|
34
|
+
ctx.emitNetworkEvent?.(event);
|
|
35
|
+
if (event.type === "request") {
|
|
36
|
+
events.push({
|
|
37
|
+
type: "http.request",
|
|
38
|
+
requestId: event.requestId,
|
|
39
|
+
url: event.url,
|
|
40
|
+
method: event.method,
|
|
41
|
+
headers: headersRecordToList(event.headers),
|
|
42
|
+
timestamp: event.timestamp,
|
|
43
|
+
frameId: event.frameId,
|
|
44
|
+
resourceType: event.resourceType,
|
|
45
|
+
initiator: event.initiator
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (event.type === "failed") {
|
|
50
|
+
events.push({
|
|
51
|
+
type: "http.failed",
|
|
52
|
+
requestId: event.requestId,
|
|
53
|
+
url: event.url,
|
|
54
|
+
errorText: event.errorText,
|
|
55
|
+
timestamp: event.timestamp
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const bodyRef = event.body
|
|
60
|
+
? await contentStore.put(event.body, {
|
|
61
|
+
url: event.url,
|
|
62
|
+
mimeType: event.mimeType,
|
|
63
|
+
sizeHint: undefined
|
|
64
|
+
})
|
|
65
|
+
: undefined;
|
|
66
|
+
events.push({
|
|
67
|
+
type: "http.response",
|
|
68
|
+
requestId: event.requestId,
|
|
69
|
+
url: event.url,
|
|
70
|
+
status: event.status,
|
|
71
|
+
statusText: event.statusText,
|
|
72
|
+
headers: headersRecordToList(event.headers),
|
|
73
|
+
timestamp: event.timestamp,
|
|
74
|
+
mimeType: event.mimeType,
|
|
75
|
+
fromDiskCache: event.fromDiskCache,
|
|
76
|
+
fromServiceWorker: event.fromServiceWorker,
|
|
77
|
+
bodyRef,
|
|
78
|
+
bodySize: undefined
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const stateKey = "captureHttpLighterceptor.session";
|
|
82
|
+
ctx.onInit(async () => {
|
|
83
|
+
const target = ctx.entry.kind === "url"
|
|
84
|
+
? { kind: "url", url: ctx.entry.url }
|
|
85
|
+
: ctx.entry.kind === "html-string"
|
|
86
|
+
? {
|
|
87
|
+
kind: "html",
|
|
88
|
+
htmlString: await ctx.whenHtml().then((h) => h.htmlString),
|
|
89
|
+
baseUrl: ctx.entry.baseUrl,
|
|
90
|
+
...(ctx.entry.url ? { url: ctx.entry.url } : {})
|
|
91
|
+
}
|
|
92
|
+
: (() => {
|
|
93
|
+
throw new Error(`CaptureHttpLighterceptorPlugin does not support entry kind: ${String(ctx.entry.kind)}`);
|
|
94
|
+
})();
|
|
95
|
+
const adapter = new LighterceptorAdapter(this.adapterOptions);
|
|
96
|
+
const session = await adapter.start(target, {
|
|
97
|
+
onEvent(event) {
|
|
98
|
+
void handleNetworkEvent(event);
|
|
99
|
+
},
|
|
100
|
+
onError(error) {
|
|
101
|
+
// eslint-disable-next-line no-console
|
|
102
|
+
console.warn("[pagepocket][capture-http-lighterceptor] adapter error", error);
|
|
103
|
+
}
|
|
104
|
+
}, this.adapterOptions);
|
|
105
|
+
ctx.state[stateKey] = session;
|
|
106
|
+
// LighterceptorAdapter does not implement navigate; it fetches HTML itself.
|
|
107
|
+
// Allow tests to rely on an already-provided HTML artifact (no extra fetch).
|
|
108
|
+
if (!ctx.html) {
|
|
109
|
+
const html = await session.waitForHtml();
|
|
110
|
+
ctx.setHtml(html);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
ctx.onBeforeNetwork(async () => {
|
|
114
|
+
const session = ctx.state[stateKey];
|
|
115
|
+
if (!session) {
|
|
116
|
+
throw new Error("CaptureHttpLighterceptorPlugin internal error: missing session");
|
|
117
|
+
}
|
|
118
|
+
await session.startCapture();
|
|
119
|
+
const completionStrategies = normalizeCompletion(ctx.options.completion);
|
|
120
|
+
const idleMs = ctx.options.timeoutMs ?? 5000;
|
|
121
|
+
const maxDurationMs = ctx.options.maxDurationMs;
|
|
122
|
+
const completion = completionStrategies.length > 0
|
|
123
|
+
? completionStrategies
|
|
124
|
+
: [networkIdle(idleMs), ...(maxDurationMs !== undefined ? [timeout(maxDurationMs)] : [])];
|
|
125
|
+
if (completion.length === 1) {
|
|
126
|
+
await completion[0].wait({
|
|
127
|
+
now: () => Date.now(),
|
|
128
|
+
getStats: () => inflightTracker.getStats()
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
await Promise.race(completion.map((strategy) => strategy.wait({
|
|
133
|
+
now: () => Date.now(),
|
|
134
|
+
getStats: () => inflightTracker.getStats()
|
|
135
|
+
})));
|
|
136
|
+
}
|
|
137
|
+
await session.stop();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Unit, type CaptureArtifacts } from "@pagepocket/lib";
|
|
2
|
+
import { type LighterceptorAdapterOptions } from "./internal/lighterceptor-adapter.js";
|
|
3
|
+
export type CaptureHttpLighterceptorUnitOptions = LighterceptorAdapterOptions;
|
|
4
|
+
export declare class CaptureHttpLighterceptorUnit extends Unit {
|
|
5
|
+
readonly id = "captureHttpLighterceptor";
|
|
6
|
+
readonly kind = "capture.http.lighterceptor";
|
|
7
|
+
private adapterOptions;
|
|
8
|
+
constructor(options?: CaptureHttpLighterceptorUnitOptions);
|
|
9
|
+
run(ctx: import("@pagepocket/lib").UnitContext, rt: import("@pagepocket/lib").UnitRuntime): Promise<{
|
|
10
|
+
capture: CaptureArtifacts;
|
|
11
|
+
html: {};
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { NETWORK } from "@pagepocket/contracts";
|
|
2
|
+
import { Unit, createMemoryContentStore, InflightTracker, mapKind, networkIdle, normalizeCompletion, throwUnsupportedEntryKind, timeout } from "@pagepocket/lib";
|
|
3
|
+
import { LighterceptorAdapter } from "./internal/lighterceptor-adapter.js";
|
|
4
|
+
const headersRecordToList = (headers) => {
|
|
5
|
+
if (!headers)
|
|
6
|
+
return [];
|
|
7
|
+
return Object.keys(headers).map((name) => ({ name, value: headers[name] }));
|
|
8
|
+
};
|
|
9
|
+
const targetBuilders = {
|
|
10
|
+
url: (entry) => ({
|
|
11
|
+
kind: "url",
|
|
12
|
+
url: entry.url
|
|
13
|
+
}),
|
|
14
|
+
"html-string": (entry) => ({
|
|
15
|
+
kind: "html",
|
|
16
|
+
htmlString: entry.htmlString,
|
|
17
|
+
baseUrl: entry.baseUrl,
|
|
18
|
+
...(entry.url ? { url: entry.url } : {})
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
export class CaptureHttpLighterceptorUnit extends Unit {
|
|
22
|
+
constructor(options) {
|
|
23
|
+
super();
|
|
24
|
+
this.id = "captureHttpLighterceptor";
|
|
25
|
+
this.kind = "capture.http.lighterceptor";
|
|
26
|
+
this.adapterOptions = options ?? {};
|
|
27
|
+
}
|
|
28
|
+
async run(ctx, rt) {
|
|
29
|
+
const contentStore = createMemoryContentStore("capture-http-lighterceptor");
|
|
30
|
+
const events = [];
|
|
31
|
+
const capabilities = {
|
|
32
|
+
requestHeaders: "approx",
|
|
33
|
+
responseHeaders: "approx",
|
|
34
|
+
requestBodies: false,
|
|
35
|
+
responseBodies: "decoded",
|
|
36
|
+
httpVersion: false,
|
|
37
|
+
remoteIp: false,
|
|
38
|
+
headerOrderPreserved: false
|
|
39
|
+
};
|
|
40
|
+
const inflightTracker = new InflightTracker();
|
|
41
|
+
const handleNetworkEvent = async (event) => {
|
|
42
|
+
inflightTracker.handleEvent(event);
|
|
43
|
+
rt.publish(NETWORK, event);
|
|
44
|
+
if (event.type === "request") {
|
|
45
|
+
events.push({
|
|
46
|
+
type: "http.request",
|
|
47
|
+
requestId: event.requestId,
|
|
48
|
+
url: event.url,
|
|
49
|
+
method: event.method,
|
|
50
|
+
headers: headersRecordToList(event.headers),
|
|
51
|
+
timestamp: event.timestamp,
|
|
52
|
+
frameId: event.frameId,
|
|
53
|
+
resourceType: event.resourceType,
|
|
54
|
+
initiator: event.initiator
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.type === "failed") {
|
|
59
|
+
events.push({
|
|
60
|
+
type: "http.failed",
|
|
61
|
+
requestId: event.requestId,
|
|
62
|
+
url: event.url,
|
|
63
|
+
errorText: event.errorText,
|
|
64
|
+
timestamp: event.timestamp
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const bodyRef = event.body
|
|
69
|
+
? await contentStore.put(event.body, {
|
|
70
|
+
url: event.url,
|
|
71
|
+
mimeType: event.mimeType,
|
|
72
|
+
sizeHint: undefined
|
|
73
|
+
})
|
|
74
|
+
: undefined;
|
|
75
|
+
events.push({
|
|
76
|
+
type: "http.response",
|
|
77
|
+
requestId: event.requestId,
|
|
78
|
+
url: event.url,
|
|
79
|
+
status: event.status,
|
|
80
|
+
statusText: event.statusText,
|
|
81
|
+
headers: headersRecordToList(event.headers),
|
|
82
|
+
timestamp: event.timestamp,
|
|
83
|
+
mimeType: event.mimeType,
|
|
84
|
+
fromDiskCache: event.fromDiskCache,
|
|
85
|
+
fromServiceWorker: event.fromServiceWorker,
|
|
86
|
+
bodyRef,
|
|
87
|
+
bodySize: undefined
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const capture = { events, contentStore, capabilities };
|
|
91
|
+
const adapter = new LighterceptorAdapter(this.adapterOptions);
|
|
92
|
+
const target = mapKind(rt.entry, targetBuilders, {
|
|
93
|
+
onUnsupportedKind: throwUnsupportedEntryKind("CaptureHttpLighterceptorUnit")
|
|
94
|
+
});
|
|
95
|
+
const session = await adapter.start(target, {
|
|
96
|
+
onEvent: (event) => {
|
|
97
|
+
void handleNetworkEvent(event);
|
|
98
|
+
},
|
|
99
|
+
onError: (error) => {
|
|
100
|
+
console.warn("[pagepocket][capture-http-lighterceptor] adapter error", error);
|
|
101
|
+
}
|
|
102
|
+
}, this.adapterOptions);
|
|
103
|
+
const html = ctx.value.html ?? (await session.waitForHtml());
|
|
104
|
+
await session.startCapture();
|
|
105
|
+
const completionStrategies = normalizeCompletion(rt.options.completion);
|
|
106
|
+
const idleMs = rt.options.timeoutMs ?? 5000;
|
|
107
|
+
const maxDurationMs = rt.options.maxDurationMs;
|
|
108
|
+
const completion = completionStrategies.length > 0
|
|
109
|
+
? completionStrategies
|
|
110
|
+
: [networkIdle(idleMs), ...(maxDurationMs !== undefined ? [timeout(maxDurationMs)] : [])];
|
|
111
|
+
if (completion.length === 1) {
|
|
112
|
+
await completion[0].wait({
|
|
113
|
+
now: () => Date.now(),
|
|
114
|
+
getStats: () => inflightTracker.getStats()
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
await Promise.race(completion.map((strategy) => strategy.wait({
|
|
119
|
+
now: () => Date.now(),
|
|
120
|
+
getStats: () => inflightTracker.getStats()
|
|
121
|
+
})));
|
|
122
|
+
}
|
|
123
|
+
await session.stop();
|
|
124
|
+
return { capture, html };
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CaptureHttpLighterceptorUnit } from "./capture-http-lighterceptor-unit.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { InterceptSession, InterceptTarget, NetworkEventHandlers, NetworkInterceptorAdapter, TriggerAction } from "@pagepocket/lib";
|
|
2
|
+
import { type LighterceptorOptions } from "@pagepocket/lighterceptor";
|
|
3
|
+
export type LighterceptorAdapterOptions = LighterceptorOptions & {
|
|
4
|
+
triggerActions?: TriggerAction[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Internal adapter for the capture plugin.
|
|
8
|
+
*
|
|
9
|
+
* Note: this is intentionally NOT published as a separate package.
|
|
10
|
+
*/
|
|
11
|
+
export declare class LighterceptorAdapter implements NetworkInterceptorAdapter {
|
|
12
|
+
readonly name = "lighterceptor";
|
|
13
|
+
readonly capabilities: {
|
|
14
|
+
canGetResponseBody: boolean;
|
|
15
|
+
canStreamResponseBody: boolean;
|
|
16
|
+
canGetRequestBody: boolean;
|
|
17
|
+
providesResourceType: boolean;
|
|
18
|
+
canWaitForHtml: boolean;
|
|
19
|
+
supportsStagedCapture: boolean;
|
|
20
|
+
};
|
|
21
|
+
private options;
|
|
22
|
+
constructor(options?: LighterceptorAdapterOptions);
|
|
23
|
+
start(target: InterceptTarget, handlers: NetworkEventHandlers, options?: LighterceptorAdapterOptions): Promise<InterceptSession>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Lighterceptor } from "@pagepocket/lighterceptor";
|
|
2
|
+
import { runTriggerActions } from "./trigger-actions.js";
|
|
3
|
+
import { decodeBase64 } from "./utils/base64.js";
|
|
4
|
+
import { getHeaderValue } from "./utils/headers.js";
|
|
5
|
+
import { inferResourceType } from "./utils/resource-type.js";
|
|
6
|
+
const encodeUtf8 = (input) => new TextEncoder().encode(input);
|
|
7
|
+
const toRequestEvent = (record) => {
|
|
8
|
+
const headers = record.headers ?? {};
|
|
9
|
+
const headersForType = record.responseHeaders && Object.keys(record.responseHeaders).length > 0
|
|
10
|
+
? record.responseHeaders
|
|
11
|
+
: headers;
|
|
12
|
+
const resourceType = inferResourceType(record.source, headersForType);
|
|
13
|
+
const event = {
|
|
14
|
+
type: "request",
|
|
15
|
+
requestId: record.requestId,
|
|
16
|
+
url: record.url,
|
|
17
|
+
method: record.method,
|
|
18
|
+
headers,
|
|
19
|
+
resourceType: resourceType,
|
|
20
|
+
timestamp: record.timestamp
|
|
21
|
+
};
|
|
22
|
+
return event;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Internal adapter for the capture plugin.
|
|
26
|
+
*
|
|
27
|
+
* Note: this is intentionally NOT published as a separate package.
|
|
28
|
+
*/
|
|
29
|
+
export class LighterceptorAdapter {
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.name = "lighterceptor";
|
|
32
|
+
this.capabilities = {
|
|
33
|
+
canGetResponseBody: true,
|
|
34
|
+
canStreamResponseBody: false,
|
|
35
|
+
canGetRequestBody: false,
|
|
36
|
+
providesResourceType: true,
|
|
37
|
+
canWaitForHtml: true,
|
|
38
|
+
supportsStagedCapture: true
|
|
39
|
+
};
|
|
40
|
+
this.options = options;
|
|
41
|
+
}
|
|
42
|
+
async start(target, handlers, options) {
|
|
43
|
+
const mergedOptions = {
|
|
44
|
+
...this.options,
|
|
45
|
+
...(options ?? {})
|
|
46
|
+
};
|
|
47
|
+
let stopped = false;
|
|
48
|
+
let startedCapture = false;
|
|
49
|
+
let htmlArtifact;
|
|
50
|
+
const htmlPromise = (async () => {
|
|
51
|
+
if (target.kind === "html") {
|
|
52
|
+
return {
|
|
53
|
+
htmlString: target.htmlString,
|
|
54
|
+
baseUrl: target.baseUrl,
|
|
55
|
+
url: target.url,
|
|
56
|
+
contentType: "text/html"
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (target.kind !== "url") {
|
|
60
|
+
throw new Error("LighterceptorAdapter only supports url or html targets.");
|
|
61
|
+
}
|
|
62
|
+
if (typeof fetch !== "function") {
|
|
63
|
+
throw new Error("Global fetch is required for LighterceptorAdapter url targets.");
|
|
64
|
+
}
|
|
65
|
+
const response = await fetch(target.url, {
|
|
66
|
+
headers: mergedOptions.headers
|
|
67
|
+
});
|
|
68
|
+
const htmlString = await response.text();
|
|
69
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
70
|
+
const url = response.url || target.url;
|
|
71
|
+
return {
|
|
72
|
+
htmlString,
|
|
73
|
+
baseUrl: url,
|
|
74
|
+
url,
|
|
75
|
+
contentType
|
|
76
|
+
};
|
|
77
|
+
})();
|
|
78
|
+
const waitForHtml = async () => {
|
|
79
|
+
if (htmlArtifact) {
|
|
80
|
+
return htmlArtifact;
|
|
81
|
+
}
|
|
82
|
+
htmlArtifact = await htmlPromise;
|
|
83
|
+
return htmlArtifact;
|
|
84
|
+
};
|
|
85
|
+
const emitPrimaryDocumentEvents = (artifact) => {
|
|
86
|
+
const docUrl = artifact.url ?? artifact.baseUrl;
|
|
87
|
+
if (!docUrl) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const requestId = `doc:${docUrl}:${now}`;
|
|
92
|
+
const requestEvent = {
|
|
93
|
+
type: "request",
|
|
94
|
+
requestId,
|
|
95
|
+
url: docUrl,
|
|
96
|
+
method: "GET",
|
|
97
|
+
headers: {},
|
|
98
|
+
resourceType: "document",
|
|
99
|
+
timestamp: now
|
|
100
|
+
};
|
|
101
|
+
handlers.onEvent(requestEvent);
|
|
102
|
+
const headers = {};
|
|
103
|
+
if (artifact.contentType) {
|
|
104
|
+
headers["content-type"] = artifact.contentType;
|
|
105
|
+
}
|
|
106
|
+
const responseEvent = {
|
|
107
|
+
type: "response",
|
|
108
|
+
requestId,
|
|
109
|
+
url: docUrl,
|
|
110
|
+
status: 200,
|
|
111
|
+
statusText: "OK",
|
|
112
|
+
headers,
|
|
113
|
+
mimeType: artifact.contentType,
|
|
114
|
+
timestamp: now,
|
|
115
|
+
body: {
|
|
116
|
+
kind: "buffer",
|
|
117
|
+
data: new TextEncoder().encode(artifact.htmlString)
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
handlers.onEvent(responseEvent);
|
|
121
|
+
};
|
|
122
|
+
const startCapture = async () => {
|
|
123
|
+
if (startedCapture) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
startedCapture = true;
|
|
127
|
+
const artifact = await waitForHtml();
|
|
128
|
+
if (stopped) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
emitPrimaryDocumentEvents(artifact);
|
|
132
|
+
// Note: `timeoutMs` is defined as the library-level *network idle duration*.
|
|
133
|
+
// LighterceptorAdapter does not currently map this to an internal wait.
|
|
134
|
+
const settleTimeMs = mergedOptions.settleTimeMs;
|
|
135
|
+
const lighterceptor = new Lighterceptor(artifact.htmlString, {
|
|
136
|
+
recursion: true,
|
|
137
|
+
baseUrl: artifact.baseUrl,
|
|
138
|
+
...mergedOptions,
|
|
139
|
+
...(settleTimeMs !== undefined ? { settleTimeMs } : {})
|
|
140
|
+
});
|
|
141
|
+
const result = await lighterceptor.run();
|
|
142
|
+
const networkRecords = result.networkRecords ?? [];
|
|
143
|
+
let sequence = 0;
|
|
144
|
+
for (const record of networkRecords) {
|
|
145
|
+
if (stopped) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const requestId = `${record.url}:${record.timestamp}:${sequence++}`;
|
|
149
|
+
const requestEvent = toRequestEvent({
|
|
150
|
+
url: record.url,
|
|
151
|
+
method: record.method || "GET",
|
|
152
|
+
source: record.source,
|
|
153
|
+
timestamp: record.timestamp,
|
|
154
|
+
responseHeaders: record.response?.headers,
|
|
155
|
+
requestId
|
|
156
|
+
});
|
|
157
|
+
handlers.onEvent(requestEvent);
|
|
158
|
+
if (record.response) {
|
|
159
|
+
const headers = record.response.headers || {};
|
|
160
|
+
const responseEvent = {
|
|
161
|
+
type: "response",
|
|
162
|
+
requestId: requestEvent.requestId,
|
|
163
|
+
url: record.url,
|
|
164
|
+
status: record.response.status,
|
|
165
|
+
statusText: record.response.statusText,
|
|
166
|
+
headers,
|
|
167
|
+
mimeType: getHeaderValue(headers, "content-type"),
|
|
168
|
+
timestamp: record.timestamp,
|
|
169
|
+
body: {
|
|
170
|
+
kind: "buffer",
|
|
171
|
+
data: record.response.bodyEncoding === "base64"
|
|
172
|
+
? decodeBase64(record.response.body)
|
|
173
|
+
: encodeUtf8(record.response.body)
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
handlers.onEvent(responseEvent);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (record.error) {
|
|
180
|
+
const failedEvent = {
|
|
181
|
+
type: "failed",
|
|
182
|
+
requestId: requestEvent.requestId,
|
|
183
|
+
url: record.url,
|
|
184
|
+
errorText: record.error,
|
|
185
|
+
timestamp: record.timestamp
|
|
186
|
+
};
|
|
187
|
+
handlers.onEvent(failedEvent);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
await runTriggerActions(mergedOptions.triggerActions);
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
waitForHtml,
|
|
194
|
+
startCapture,
|
|
195
|
+
stop: async () => {
|
|
196
|
+
stopped = true;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { TriggerActionValues } from "@pagepocket/lib";
|
|
2
|
+
export const runTriggerActions = async (actions = []) => {
|
|
3
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
for (const action of actions) {
|
|
7
|
+
if (action === TriggerActionValues.HOVER) {
|
|
8
|
+
await simulateHoverAll();
|
|
9
|
+
}
|
|
10
|
+
if (action === TriggerActionValues.SCROLL_TO_END) {
|
|
11
|
+
await simulateScrollToEnd();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const simulateHoverAll = async () => {
|
|
16
|
+
const elements = Array.from(document.querySelectorAll("*"));
|
|
17
|
+
for (const element of elements) {
|
|
18
|
+
const rect = element.getBoundingClientRect();
|
|
19
|
+
const x = rect.left + rect.width / 2;
|
|
20
|
+
const y = rect.top + rect.height / 2;
|
|
21
|
+
const event = new MouseEvent("mouseover", {
|
|
22
|
+
bubbles: true,
|
|
23
|
+
cancelable: true,
|
|
24
|
+
clientX: x,
|
|
25
|
+
clientY: y
|
|
26
|
+
});
|
|
27
|
+
element.dispatchEvent(event);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const simulateScrollToEnd = async () => {
|
|
31
|
+
const scrollHeight = document.documentElement?.scrollHeight ?? document.body?.scrollHeight;
|
|
32
|
+
if (typeof scrollHeight === "number") {
|
|
33
|
+
window.scrollTo({ top: scrollHeight });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const decodeBase64: (input: string) => Uint8Array;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const decodeBase64 = (input) => {
|
|
2
|
+
if (typeof atob === "function") {
|
|
3
|
+
const binary = atob(input);
|
|
4
|
+
const bytes = new Uint8Array(binary.length);
|
|
5
|
+
for (let i = 0; i < binary.length; i++) {
|
|
6
|
+
bytes[i] = binary.charCodeAt(i);
|
|
7
|
+
}
|
|
8
|
+
return bytes;
|
|
9
|
+
}
|
|
10
|
+
// Node fallback
|
|
11
|
+
return Uint8Array.from(Buffer.from(input, "base64"));
|
|
12
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getHeaderValue: (headers: Record<string, string>, name: string) => string | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getHeaderValue } from "./headers.js";
|
|
2
|
+
export const inferResourceType = (source, headers) => {
|
|
3
|
+
if (source === "fetch")
|
|
4
|
+
return "fetch";
|
|
5
|
+
if (source === "xhr")
|
|
6
|
+
return "xhr";
|
|
7
|
+
if (source === "css")
|
|
8
|
+
return "stylesheet";
|
|
9
|
+
if (source === "img")
|
|
10
|
+
return "image";
|
|
11
|
+
const contentType = (getHeaderValue(headers, "content-type") || "").toLowerCase();
|
|
12
|
+
if (contentType.includes("text/html"))
|
|
13
|
+
return "document";
|
|
14
|
+
if (contentType.includes("text/css"))
|
|
15
|
+
return "stylesheet";
|
|
16
|
+
if (contentType.includes("javascript"))
|
|
17
|
+
return "script";
|
|
18
|
+
if (contentType.startsWith("image/"))
|
|
19
|
+
return "image";
|
|
20
|
+
if (contentType.startsWith("font/") || contentType.includes("woff"))
|
|
21
|
+
return "font";
|
|
22
|
+
if (contentType.startsWith("audio/") || contentType.startsWith("video/"))
|
|
23
|
+
return "media";
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pagepocket/capture-http-lighterceptor-unit",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "PagePocket plugin: capture HTTP events (lighterceptor adapter)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@pagepocket/lib": "0.8.0",
|
|
14
|
+
"@pagepocket/contracts": "0.8.0",
|
|
15
|
+
"@pagepocket/lighterceptor": "0.8.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.4.5"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"test": "node -e \"process.exit(0)\""
|
|
23
|
+
}
|
|
24
|
+
}
|