@pagepocket/lib 0.13.0 → 0.14.5
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/hackers/index.js +3 -1
- package/dist/hackers/replay-dom-rewrite/script-part-1.d.ts +1 -1
- package/dist/hackers/replay-dom-rewrite/script-part-1.js +40 -51
- package/dist/hackers/replay-dom-rewrite/script-part-2.d.ts +1 -1
- package/dist/hackers/replay-dom-rewrite/script-part-2.js +74 -44
- package/dist/hackers/replay-fetch.js +4 -0
- package/dist/hackers/replay-websocket.js +50 -8
- package/dist/hackers/replay-worker.d.ts +18 -0
- package/dist/hackers/replay-worker.js +242 -0
- package/dist/replay/match-api.js +103 -3
- package/dist/replay/templates/loader-template.d.ts +15 -0
- package/dist/replay/templates/loader-template.js +164 -0
- package/dist/replay/templates/match-api-source.d.ts +1 -1
- package/dist/replay/templates/match-api-source.js +86 -4
- package/dist/replay/templates/replay-script-template.part-2.js +24 -1
- package/dist/resource-filter.js +29 -3
- package/dist/snapshot-builder/build-snapshot.js +33 -3
- package/dist/units/runner.js +15 -0
- package/package.json +4 -4
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercept `new Worker(url|blob)` during replay so that worker-internal
|
|
3
|
+
* `fetch` / `XHR` requests are served from the saved api.json snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: create a **real** Worker (preserving the full Worker global scope)
|
|
6
|
+
* but prepend the replay fetch/XHR patches and the api.json records to the
|
|
7
|
+
* worker script source. This avoids the impossible task of perfectly
|
|
8
|
+
* emulating the Worker global on the main thread.
|
|
9
|
+
*
|
|
10
|
+
* The injected preamble inside the worker:
|
|
11
|
+
* 1. Defines a minimal `matchAPI` + `findRecord` using the serialised records.
|
|
12
|
+
* 2. Monkey-patches `self.fetch` and `self.XMLHttpRequest.prototype` so
|
|
13
|
+
* network requests hit the saved records instead of the (offline) network.
|
|
14
|
+
* 3. Hands off to the original worker script, which now runs in a genuine
|
|
15
|
+
* Worker thread with all native APIs intact.
|
|
16
|
+
*/
|
|
17
|
+
export const replayWorkerStub = {
|
|
18
|
+
id: "replay-worker-stub",
|
|
19
|
+
stage: "replay",
|
|
20
|
+
build: () => `
|
|
21
|
+
if (typeof Worker !== "undefined") {
|
|
22
|
+
var OriginalWorker = Worker;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the JS preamble that will be prepended to every worker script.
|
|
26
|
+
* It serialises the current api snapshot records and injects a self-
|
|
27
|
+
* contained fetch/XHR replay layer.
|
|
28
|
+
*/
|
|
29
|
+
function __pagepocketBuildWorkerPreamble() {
|
|
30
|
+
var snapshot = window.__pagepocketApiSnapshot;
|
|
31
|
+
var records = (snapshot && snapshot.records) ? snapshot.records : [];
|
|
32
|
+
var baseUrl = (snapshot && snapshot.url) ? snapshot.url : location.href;
|
|
33
|
+
|
|
34
|
+
// Serialise records once. This is evaluated inside the Worker as a
|
|
35
|
+
// string literal, so we JSON-encode then embed.
|
|
36
|
+
var recordsJSON = JSON.stringify(records);
|
|
37
|
+
|
|
38
|
+
return '(function(){' +
|
|
39
|
+
'var __records = ' + recordsJSON + ';' +
|
|
40
|
+
'var __baseUrl = ' + JSON.stringify(baseUrl) + ';' +
|
|
41
|
+
|
|
42
|
+
// ---------- matchAPI (inlined, self-contained) ----------
|
|
43
|
+
'var matchAPI = ' + (typeof matchAPI === 'function' ? matchAPI.toString() : 'function(){return undefined}') + ';' +
|
|
44
|
+
|
|
45
|
+
// ---------- findRecord ----------
|
|
46
|
+
'var __byKey = new Map();' +
|
|
47
|
+
'var makeVariantKeys = function(m, u, b) {' +
|
|
48
|
+
' var makeKey = function(km, ku, kb) { return km.toUpperCase() + " " + ku + " " + kb; };' +
|
|
49
|
+
' var keys = [makeKey(m, u, b)];' +
|
|
50
|
+
' try { keys.push(makeKey(m, new URL(u, __baseUrl).toString(), b)); } catch(e){}' +
|
|
51
|
+
' if (b) { keys.push(makeKey(m, u, "")); try { keys.push(makeKey(m, new URL(u, __baseUrl).toString(), "")); } catch(e){} }' +
|
|
52
|
+
' return keys;' +
|
|
53
|
+
'};' +
|
|
54
|
+
'for (var i = 0; i < __records.length; i++) {' +
|
|
55
|
+
' var r = __records[i];' +
|
|
56
|
+
' if (!r || !r.url || !r.method) continue;' +
|
|
57
|
+
' var ks = makeVariantKeys(r.method, r.url, r.requestBody || r.requestBodyBase64 || "");' +
|
|
58
|
+
' for (var j = 0; j < ks.length; j++) { if (!__byKey.has(ks[j])) __byKey.set(ks[j], r); }' +
|
|
59
|
+
'}' +
|
|
60
|
+
'function findRecord(method, url, body) {' +
|
|
61
|
+
' return matchAPI({ records: __records, byKey: __byKey, baseUrl: __baseUrl, method: method, url: url, body: body });' +
|
|
62
|
+
'}' +
|
|
63
|
+
|
|
64
|
+
// ---------- decodeBase64 ----------
|
|
65
|
+
'function decodeBase64(b64) {' +
|
|
66
|
+
' var bin = atob(b64); var arr = new Uint8Array(bin.length);' +
|
|
67
|
+
' for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);' +
|
|
68
|
+
' return arr;' +
|
|
69
|
+
'}' +
|
|
70
|
+
|
|
71
|
+
// ---------- patch fetch ----------
|
|
72
|
+
'var originalFetch = self.fetch;' +
|
|
73
|
+
'self.fetch = function(input, init) {' +
|
|
74
|
+
' var url = (typeof input === "string") ? input : (input && input.url) ? input.url : String(input);' +
|
|
75
|
+
' var method = (init && init.method) ? init.method : "GET";' +
|
|
76
|
+
' var body = (init && init.body) ? init.body : "";' +
|
|
77
|
+
' var record;' +
|
|
78
|
+
' try { record = findRecord(method, url, body); } catch(e) {}' +
|
|
79
|
+
' if (record) {' +
|
|
80
|
+
' var headers = new Headers();' +
|
|
81
|
+
' var rh = record.responseHeaders || {};' +
|
|
82
|
+
' for (var k in rh) { try { headers.append(k, rh[k]); } catch(e) {} }' +
|
|
83
|
+
' if (record.responseEncoding === "base64" && record.responseBodyBase64) {' +
|
|
84
|
+
' return Promise.resolve(new Response(decodeBase64(record.responseBodyBase64), { status: record.status || 200, statusText: record.statusText || "OK", headers: headers }));' +
|
|
85
|
+
' }' +
|
|
86
|
+
' return Promise.resolve(new Response(record.responseBody || "", { status: record.status || 200, statusText: record.statusText || "OK", headers: headers }));' +
|
|
87
|
+
' }' +
|
|
88
|
+
' return originalFetch.apply(self, arguments);' +
|
|
89
|
+
'};' +
|
|
90
|
+
|
|
91
|
+
// ---------- patch XHR ----------
|
|
92
|
+
'var origXHROpen = XMLHttpRequest.prototype.open;' +
|
|
93
|
+
'var origXHRSend = XMLHttpRequest.prototype.send;' +
|
|
94
|
+
'XMLHttpRequest.prototype.open = function(m, u) { this.__ppM = m; this.__ppU = u; return origXHROpen.apply(this, arguments); };' +
|
|
95
|
+
'XMLHttpRequest.prototype.send = function(body) {' +
|
|
96
|
+
' var xhr = this; var method = xhr.__ppM || "GET"; var url = xhr.__ppU || "";' +
|
|
97
|
+
' var record; try { record = findRecord(method, url, body); } catch(e) {}' +
|
|
98
|
+
' if (record) {' +
|
|
99
|
+
' var defineP = function(o,k,v){try{Object.defineProperty(o,k,{value:v,writable:true,configurable:true})}catch(e){try{o[k]=v}catch(e2){}}};' +
|
|
100
|
+
' setTimeout(function() {' +
|
|
101
|
+
' defineP(xhr,"readyState",4); defineP(xhr,"status",record.status||200); defineP(xhr,"statusText",record.statusText||"OK");' +
|
|
102
|
+
' if (xhr.responseType==="arraybuffer"&&record.responseBodyBase64){defineP(xhr,"response",decodeBase64(record.responseBodyBase64).buffer);defineP(xhr,"responseText","");}' +
|
|
103
|
+
' else if (xhr.responseType==="blob"&&record.responseBodyBase64){defineP(xhr,"response",new Blob([decodeBase64(record.responseBodyBase64)]));defineP(xhr,"responseText","");}' +
|
|
104
|
+
' else{defineP(xhr,"response",record.responseBody||"");defineP(xhr,"responseText",record.responseBody||"");}' +
|
|
105
|
+
' if(typeof xhr.onreadystatechange==="function")xhr.onreadystatechange();' +
|
|
106
|
+
' if(typeof xhr.onload==="function")xhr.onload(new Event("load"));' +
|
|
107
|
+
' if(typeof xhr.onloadend==="function")xhr.onloadend(new Event("loadend"));' +
|
|
108
|
+
' }, 0); return;' +
|
|
109
|
+
' }' +
|
|
110
|
+
' var st=record?undefined:404;' +
|
|
111
|
+
' setTimeout(function(){' +
|
|
112
|
+
' defineP(xhr,"readyState",4);defineP(xhr,"status",404);defineP(xhr,"statusText","Not Found");' +
|
|
113
|
+
' defineP(xhr,"response","");defineP(xhr,"responseText","");' +
|
|
114
|
+
' if(typeof xhr.onreadystatechange==="function")xhr.onreadystatechange();' +
|
|
115
|
+
' if(typeof xhr.onload==="function")xhr.onload(new Event("load"));' +
|
|
116
|
+
' },0);' +
|
|
117
|
+
'};' +
|
|
118
|
+
|
|
119
|
+
'})();\\n';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
window.Worker = function PagepocketWorkerProxy(urlOrBlob, options) {
|
|
123
|
+
var preamble = __pagepocketBuildWorkerPreamble();
|
|
124
|
+
|
|
125
|
+
var scriptUrl = (typeof urlOrBlob === 'string') ? urlOrBlob
|
|
126
|
+
: (urlOrBlob instanceof URL) ? urlOrBlob.href
|
|
127
|
+
: '';
|
|
128
|
+
|
|
129
|
+
// Use rawFetch (unpatched) for blob: URLs — they are in-memory Blob objects
|
|
130
|
+
// that the replay fetch would fail to match against api.json.
|
|
131
|
+
// Use window.fetch (patched) for regular URLs — the worker script is saved
|
|
132
|
+
// in the snapshot and must be served via the replay layer (especially offline).
|
|
133
|
+
var rawFetch = (window.fetch && window.fetch.__pagepocketOriginal) || fetch;
|
|
134
|
+
var fetchForUrl = function(url) {
|
|
135
|
+
return (url && url.startsWith('blob:')) ? rawFetch(url) : window.fetch(url);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Helper: create a real Worker from source code string.
|
|
139
|
+
var createWorkerFromSource = function(originalCode) {
|
|
140
|
+
var combined = preamble + originalCode;
|
|
141
|
+
var blob = new Blob([combined], { type: 'application/javascript' });
|
|
142
|
+
var blobUrl = URL.createObjectURL(blob);
|
|
143
|
+
try {
|
|
144
|
+
return new OriginalWorker(blobUrl, options);
|
|
145
|
+
} finally {
|
|
146
|
+
// Revoke after a tick so the Worker has time to load.
|
|
147
|
+
setTimeout(function() { URL.revokeObjectURL(blobUrl); }, 5000);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// For blob: URLs, fetch the source, prepend preamble, create new blob Worker.
|
|
152
|
+
if (scriptUrl.startsWith('blob:')) {
|
|
153
|
+
// We need to return a Worker-like object synchronously, but blob fetch is async.
|
|
154
|
+
// Create a "pending" real Worker that we'll swap in once the source is fetched.
|
|
155
|
+
// Use a thin proxy that queues postMessage calls until the real Worker is ready.
|
|
156
|
+
var pendingMessages = [];
|
|
157
|
+
var realWorker = null;
|
|
158
|
+
var proxy = {
|
|
159
|
+
postMessage: function(data, transfer) {
|
|
160
|
+
if (realWorker) { realWorker.postMessage(data, transfer); }
|
|
161
|
+
else { pendingMessages.push([data, transfer]); }
|
|
162
|
+
},
|
|
163
|
+
terminate: function() { if (realWorker) realWorker.terminate(); realWorker = { postMessage: function(){}, terminate: function(){} }; },
|
|
164
|
+
addEventListener: function(t, fn) { if (realWorker) realWorker.addEventListener(t, fn); else (proxy.__listeners = proxy.__listeners || []).push([t, fn]); },
|
|
165
|
+
removeEventListener: function(t, fn) { if (realWorker) realWorker.removeEventListener(t, fn); },
|
|
166
|
+
dispatchEvent: function(e) { if (realWorker) return realWorker.dispatchEvent(e); return false; },
|
|
167
|
+
onmessage: null,
|
|
168
|
+
onerror: null
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
fetchForUrl(scriptUrl).then(function(r) { return r.text(); }).then(function(code) {
|
|
172
|
+
realWorker = createWorkerFromSource(code);
|
|
173
|
+
// Wire up onmessage/onerror
|
|
174
|
+
if (proxy.onmessage) realWorker.onmessage = proxy.onmessage;
|
|
175
|
+
if (proxy.onerror) realWorker.onerror = proxy.onerror;
|
|
176
|
+
// Replay queued listeners
|
|
177
|
+
if (proxy.__listeners) {
|
|
178
|
+
for (var i = 0; i < proxy.__listeners.length; i++) {
|
|
179
|
+
realWorker.addEventListener(proxy.__listeners[i][0], proxy.__listeners[i][1]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Flush queued messages
|
|
183
|
+
for (var i = 0; i < pendingMessages.length; i++) {
|
|
184
|
+
realWorker.postMessage(pendingMessages[i][0], pendingMessages[i][1]);
|
|
185
|
+
}
|
|
186
|
+
pendingMessages = [];
|
|
187
|
+
}).catch(function(e) {
|
|
188
|
+
console.warn('[pagepocket] Worker proxy: cannot fetch blob', e);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return proxy;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// For regular URLs: fetch, prepend, create.
|
|
195
|
+
if (scriptUrl) {
|
|
196
|
+
var pendingMessages2 = [];
|
|
197
|
+
var realWorker2 = null;
|
|
198
|
+
var proxy2 = {
|
|
199
|
+
postMessage: function(data, transfer) {
|
|
200
|
+
if (realWorker2) { realWorker2.postMessage(data, transfer); }
|
|
201
|
+
else { pendingMessages2.push([data, transfer]); }
|
|
202
|
+
},
|
|
203
|
+
terminate: function() { if (realWorker2) realWorker2.terminate(); realWorker2 = { postMessage: function(){}, terminate: function(){} }; },
|
|
204
|
+
addEventListener: function(t, fn) { if (realWorker2) realWorker2.addEventListener(t, fn); else (proxy2.__listeners = proxy2.__listeners || []).push([t, fn]); },
|
|
205
|
+
removeEventListener: function(t, fn) { if (realWorker2) realWorker2.removeEventListener(t, fn); },
|
|
206
|
+
dispatchEvent: function(e) { if (realWorker2) return realWorker2.dispatchEvent(e); return false; },
|
|
207
|
+
onmessage: null,
|
|
208
|
+
onerror: null
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
fetchForUrl(scriptUrl).then(function(r) { return r.text(); }).then(function(code) {
|
|
212
|
+
realWorker2 = createWorkerFromSource(code);
|
|
213
|
+
if (proxy2.onmessage) realWorker2.onmessage = proxy2.onmessage;
|
|
214
|
+
if (proxy2.onerror) realWorker2.onerror = proxy2.onerror;
|
|
215
|
+
if (proxy2.__listeners) {
|
|
216
|
+
for (var i = 0; i < proxy2.__listeners.length; i++) {
|
|
217
|
+
realWorker2.addEventListener(proxy2.__listeners[i][0], proxy2.__listeners[i][1]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (var i = 0; i < pendingMessages2.length; i++) {
|
|
221
|
+
realWorker2.postMessage(pendingMessages2[i][0], pendingMessages2[i][1]);
|
|
222
|
+
}
|
|
223
|
+
pendingMessages2 = [];
|
|
224
|
+
}).catch(function(e) {
|
|
225
|
+
console.warn('[pagepocket] Worker proxy: cannot fetch script', e);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return proxy2;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Fallback: no URL at all, just create original.
|
|
232
|
+
return new OriginalWorker(urlOrBlob, options);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
window.Worker.CONNECTING = 0;
|
|
236
|
+
window.Worker.OPEN = 1;
|
|
237
|
+
window.Worker.CLOSING = 2;
|
|
238
|
+
window.Worker.CLOSED = 3;
|
|
239
|
+
window.Worker.__pagepocketOriginal = OriginalWorker;
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
};
|
package/dist/replay/match-api.js
CHANGED
|
@@ -120,6 +120,23 @@ export function matchAPI(options) {
|
|
|
120
120
|
}
|
|
121
121
|
return value;
|
|
122
122
|
};
|
|
123
|
+
const normalizePathLoose = (input) => {
|
|
124
|
+
const parsed = toUrlOrUndefined(input);
|
|
125
|
+
if (parsed) {
|
|
126
|
+
return normalizePath(parsed.pathname);
|
|
127
|
+
}
|
|
128
|
+
const rawValue = String(input ?? "");
|
|
129
|
+
if (!rawValue) {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
const valueWithoutHash = rawValue.split("#")[0] ?? "";
|
|
133
|
+
const valueWithoutQuery = valueWithoutHash.split("?")[0] ?? "";
|
|
134
|
+
const withoutOrigin = valueWithoutQuery.replace(/^([a-zA-Z][a-zA-Z\d+\-.]*:)?\/\/[^/]+/, "");
|
|
135
|
+
if (!withoutOrigin) {
|
|
136
|
+
return "/";
|
|
137
|
+
}
|
|
138
|
+
return normalizePath(withoutOrigin.startsWith("/") ? withoutOrigin : `/${withoutOrigin}`);
|
|
139
|
+
};
|
|
123
140
|
const urlMatches = (inputUrl, recordUrl) => {
|
|
124
141
|
// Strong match first.
|
|
125
142
|
if (urlEquivalent(inputUrl, recordUrl, {
|
|
@@ -141,9 +158,7 @@ export function matchAPI(options) {
|
|
|
141
158
|
if (leftPathSearch === rightPathSearch) {
|
|
142
159
|
return true;
|
|
143
160
|
}
|
|
144
|
-
|
|
145
|
-
const rightPath = normalizePath(right.pathname);
|
|
146
|
-
return leftPath === rightPath;
|
|
161
|
+
return false;
|
|
147
162
|
};
|
|
148
163
|
const scanRecords = (keyMethod, keyBody) => {
|
|
149
164
|
for (const record of records || []) {
|
|
@@ -164,11 +179,96 @@ export function matchAPI(options) {
|
|
|
164
179
|
}
|
|
165
180
|
return undefined;
|
|
166
181
|
};
|
|
182
|
+
const scanRecordsIgnoreQueryAndProtocol = (keyMethod, keyBody) => {
|
|
183
|
+
const inputPathname = normalizePathLoose(url);
|
|
184
|
+
if (!inputPathname) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
// Collect all pathname-matching candidates first.
|
|
188
|
+
const candidates = [];
|
|
189
|
+
for (const record of records || []) {
|
|
190
|
+
if (!record || !record.url || !record.method) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (record.method.toUpperCase() !== keyMethod) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const recordPathname = normalizePathLoose(record.url);
|
|
197
|
+
if (!recordPathname || recordPathname !== inputPathname) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const recordBody = record.requestBody || record.requestBodyBase64 || "";
|
|
201
|
+
if (keyBody && recordBody !== keyBody) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
candidates.push(record);
|
|
205
|
+
}
|
|
206
|
+
if (candidates.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
// Single candidate — check if it's a safe fallback.
|
|
210
|
+
if (candidates.length === 1) {
|
|
211
|
+
const candidate = candidates[0];
|
|
212
|
+
const inputParsed = safeUrl(url);
|
|
213
|
+
const candidateParsed = safeUrl(candidate.url);
|
|
214
|
+
if (inputParsed && candidateParsed) {
|
|
215
|
+
const inputKeys = Array.from(inputParsed.searchParams.keys());
|
|
216
|
+
const candidateKeys = new Set(candidateParsed.searchParams.keys());
|
|
217
|
+
const inputAllContained = inputKeys.length > 0 && inputKeys.every(k => candidateKeys.has(k));
|
|
218
|
+
const candidateHasExtra = candidateKeys.size > inputKeys.length;
|
|
219
|
+
// If input params are a strict subset of candidate (candidate has extra params
|
|
220
|
+
// like cursor), this is likely a different request — skip it.
|
|
221
|
+
if (inputAllContained && candidateHasExtra) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
// Multiple candidates — pick the one with the best query param overlap.
|
|
228
|
+
const inputParsed = safeUrl(url);
|
|
229
|
+
const inputSearch = inputParsed ? inputParsed.search : "";
|
|
230
|
+
let bestRecord;
|
|
231
|
+
let bestScore = -Infinity;
|
|
232
|
+
for (const candidate of candidates) {
|
|
233
|
+
const candidateParsed = safeUrl(candidate.url);
|
|
234
|
+
const candidateSearch = candidateParsed ? candidateParsed.search : "";
|
|
235
|
+
// Exact search match is ideal.
|
|
236
|
+
if (candidateSearch === inputSearch) {
|
|
237
|
+
return candidate;
|
|
238
|
+
}
|
|
239
|
+
// Score by search string similarity — shorter distance is better.
|
|
240
|
+
// Prefer candidates whose search is a prefix/subset of the input.
|
|
241
|
+
const inputParams = inputParsed ? inputParsed.searchParams : new URLSearchParams();
|
|
242
|
+
const candidateParams = candidateParsed ? candidateParsed.searchParams : new URLSearchParams();
|
|
243
|
+
let shared = 0;
|
|
244
|
+
let recordOnly = 0;
|
|
245
|
+
for (const [key, value] of candidateParams.entries()) {
|
|
246
|
+
if (inputParams.get(key) === value) {
|
|
247
|
+
shared++;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
recordOnly++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const score = shared - recordOnly * 2;
|
|
254
|
+
if (score > bestScore) {
|
|
255
|
+
bestScore = score;
|
|
256
|
+
bestRecord = candidate;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return bestRecord;
|
|
260
|
+
};
|
|
167
261
|
for (const [keyMethod, keyBody] of matchOrder) {
|
|
168
262
|
const record = scanRecords(keyMethod, keyBody);
|
|
169
263
|
if (record) {
|
|
170
264
|
return record;
|
|
171
265
|
}
|
|
172
266
|
}
|
|
267
|
+
for (const [keyMethod, keyBody] of matchOrder) {
|
|
268
|
+
const record = scanRecordsIgnoreQueryAndProtocol(keyMethod, keyBody);
|
|
269
|
+
if (record) {
|
|
270
|
+
return record;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
173
273
|
return undefined;
|
|
174
274
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type LoaderOptions = {
|
|
2
|
+
pageUrl: string;
|
|
3
|
+
apiPath: string;
|
|
4
|
+
resourcesPathUrl: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Build a loader HTML page that pre-fetches `resources_path.json`, `api.json`,
|
|
9
|
+
* and the actual page HTML in parallel. Shows an SVG spinner for at least 400ms,
|
|
10
|
+
* then fades out to reveal the page content.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* buildLoaderHtml({ pageUrl: '/_page.html', apiPath: '/api.json', resourcesPathUrl: '/resources_path.json' })
|
|
14
|
+
*/
|
|
15
|
+
export declare const buildLoaderHtml: (options: LoaderOptions) => string;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a loader HTML page that pre-fetches `resources_path.json`, `api.json`,
|
|
3
|
+
* and the actual page HTML in parallel. Shows an SVG spinner for at least 400ms,
|
|
4
|
+
* then fades out to reveal the page content.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* buildLoaderHtml({ pageUrl: '/_page.html', apiPath: '/api.json', resourcesPathUrl: '/resources_path.json' })
|
|
8
|
+
*/
|
|
9
|
+
export const buildLoaderHtml = (options) => {
|
|
10
|
+
const { pageUrl, apiPath, resourcesPathUrl, title } = options;
|
|
11
|
+
const escapedTitle = escapeHtml(title ?? "Loading\u2026");
|
|
12
|
+
return `<!DOCTYPE html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8">
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
17
|
+
<title>${escapedTitle}</title>
|
|
18
|
+
<style>
|
|
19
|
+
body { margin: 0; }
|
|
20
|
+
.pp-overlay {
|
|
21
|
+
position: fixed;
|
|
22
|
+
inset: 0;
|
|
23
|
+
z-index: 2147483647;
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
background: #fff;
|
|
28
|
+
transition: opacity 0.4s ease;
|
|
29
|
+
}
|
|
30
|
+
.pp-overlay.pp-fade-out {
|
|
31
|
+
opacity: 0;
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
}
|
|
34
|
+
.pp-spin {
|
|
35
|
+
animation: pp-rotate 0.8s linear infinite;
|
|
36
|
+
}
|
|
37
|
+
@keyframes pp-rotate { to { transform: rotate(360deg); } }
|
|
38
|
+
.pp-error {
|
|
39
|
+
color: #d32f2f;
|
|
40
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
41
|
+
white-space: pre-wrap;
|
|
42
|
+
max-width: 600px;
|
|
43
|
+
text-align: center;
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<div class="pp-overlay" id="ppOverlay">
|
|
49
|
+
${SPINNER_SVG}
|
|
50
|
+
</div>
|
|
51
|
+
<script>
|
|
52
|
+
(function () {
|
|
53
|
+
var pageUrl = ${JSON.stringify(pageUrl)};
|
|
54
|
+
var apiPath = ${JSON.stringify(apiPath)};
|
|
55
|
+
var resourcesPathUrl = ${JSON.stringify(resourcesPathUrl)};
|
|
56
|
+
var startTime = Date.now();
|
|
57
|
+
var MIN_DISPLAY_MS = 400;
|
|
58
|
+
|
|
59
|
+
function showError(message) {
|
|
60
|
+
var overlay = document.getElementById('ppOverlay');
|
|
61
|
+
if (overlay) {
|
|
62
|
+
overlay.innerHTML = '<div class="pp-error">' + message + '</div>';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fetchText(url) {
|
|
67
|
+
return fetch(url).then(function (response) {
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error('Failed to fetch ' + url + ' (' + response.status + ')');
|
|
70
|
+
}
|
|
71
|
+
return response.text();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fetchJson(url) {
|
|
76
|
+
return fetch(url).then(function (response) {
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error('Failed to fetch ' + url + ' (' + response.status + ')');
|
|
79
|
+
}
|
|
80
|
+
return response.json();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function waitMinDuration() {
|
|
85
|
+
var elapsed = Date.now() - startTime;
|
|
86
|
+
var remaining = MIN_DISPLAY_MS - elapsed;
|
|
87
|
+
|
|
88
|
+
if (remaining <= 0) {
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new Promise(function (resolve) {
|
|
93
|
+
setTimeout(resolve, remaining);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Promise.all([
|
|
98
|
+
fetchJson(resourcesPathUrl),
|
|
99
|
+
fetchJson(apiPath),
|
|
100
|
+
fetchText(pageUrl)
|
|
101
|
+
])
|
|
102
|
+
.then(function (results) {
|
|
103
|
+
var resourcesPath = results[0];
|
|
104
|
+
var apiSnapshot = results[1];
|
|
105
|
+
var pageHtml = results[2];
|
|
106
|
+
|
|
107
|
+
window.__pagepocketResourcesPath = resourcesPath;
|
|
108
|
+
window.__pagepocketApiSnapshot = apiSnapshot;
|
|
109
|
+
|
|
110
|
+
var overlayHtml = ${JSON.stringify(OVERLAY_INJECTION)};
|
|
111
|
+
|
|
112
|
+
document.open();
|
|
113
|
+
document.write(overlayHtml + pageHtml);
|
|
114
|
+
document.close();
|
|
115
|
+
|
|
116
|
+
return waitMinDuration();
|
|
117
|
+
})
|
|
118
|
+
.then(function () {
|
|
119
|
+
var overlay = document.getElementById('ppOverlay');
|
|
120
|
+
|
|
121
|
+
if (!overlay) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
overlay.classList.add('pp-fade-out');
|
|
126
|
+
overlay.addEventListener('transitionend', function () {
|
|
127
|
+
overlay.remove();
|
|
128
|
+
});
|
|
129
|
+
})
|
|
130
|
+
.catch(function (error) {
|
|
131
|
+
showError('Failed to load snapshot:\\\\n' + String(error));
|
|
132
|
+
});
|
|
133
|
+
})();
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
`;
|
|
138
|
+
};
|
|
139
|
+
const escapeHtml = (input) => input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
140
|
+
const SPINNER_SVG = [
|
|
141
|
+
'<svg class="pp-spin" width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">',
|
|
142
|
+
' <circle cx="20" cy="20" r="17" fill="none" stroke="rgba(0,0,0,0.08)" stroke-width="3"/>',
|
|
143
|
+
' <circle cx="20" cy="20" r="17" fill="none" stroke="#222" stroke-width="3"',
|
|
144
|
+
' stroke-dasharray="80 27" stroke-linecap="round"/>',
|
|
145
|
+
"</svg>"
|
|
146
|
+
].join("\n");
|
|
147
|
+
const OVERLAY_INJECTION = [
|
|
148
|
+
"<style>",
|
|
149
|
+
" .pp-overlay {",
|
|
150
|
+
" position: fixed; inset: 0; z-index: 2147483647;",
|
|
151
|
+
" display: flex; align-items: center; justify-content: center;",
|
|
152
|
+
" background: #fff;",
|
|
153
|
+
" transition: opacity 0.4s ease;",
|
|
154
|
+
" }",
|
|
155
|
+
" .pp-overlay.pp-fade-out {",
|
|
156
|
+
" opacity: 0; pointer-events: none;",
|
|
157
|
+
" }",
|
|
158
|
+
" .pp-spin {",
|
|
159
|
+
" animation: pp-rotate 0.8s linear infinite;",
|
|
160
|
+
" }",
|
|
161
|
+
" @keyframes pp-rotate { to { transform: rotate(360deg); } }",
|
|
162
|
+
"</style>",
|
|
163
|
+
`<div class="pp-overlay" id="ppOverlay">${SPINNER_SVG}</div>`
|
|
164
|
+
].join("\n");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const matchAPISource = "function matchAPI(options) {\n const { records, byKey, baseUrl, method, url, body } = options;\n const normalizeBody = (value) => {\n if (value === undefined || value === null)\n return \"\";\n if (typeof value === \"string\")\n return value;\n try {\n return String(value);\n }\n catch {\n return \"\";\n }\n };\n const normalizeUrl = (input) => {\n try {\n return new URL(input, baseUrl).toString();\n }\n catch {\n return input;\n }\n };\n const stripHash = (value) => {\n const index = value.indexOf(\"#\");\n return index === -1 ? value : value.slice(0, index);\n };\n const stripTrailingSlash = (value) => {\n if (value.length > 1 && value.endsWith(\"/\")) {\n return value.slice(0, -1);\n }\n return value;\n };\n const safeUrl = (input) => {\n try {\n return new URL(input, baseUrl);\n }\n catch {\n return null;\n }\n };\n const toPathSearch = (input) => {\n const parsed = safeUrl(input);\n if (!parsed)\n return input;\n return parsed.pathname + parsed.search;\n };\n const toPathname = (input) => {\n const parsed = safeUrl(input);\n return parsed ? parsed.pathname : input;\n };\n const buildUrlVariants = (input) => {\n const variants = new Set();\n const push = (value) => {\n if (!value)\n return;\n variants.add(value);\n };\n const raw = String(input ?? \"\");\n push(raw);\n push(stripHash(raw));\n push(stripTrailingSlash(raw));\n push(stripTrailingSlash(stripHash(raw)));\n const absolute = normalizeUrl(raw);\n push(absolute);\n const absoluteNoHash = stripHash(absolute);\n push(absoluteNoHash);\n push(stripTrailingSlash(absoluteNoHash));\n const pathSearch = toPathSearch(raw);\n push(pathSearch);\n push(stripTrailingSlash(pathSearch));\n const pathname = toPathname(raw);\n push(pathname);\n push(stripTrailingSlash(pathname));\n return Array.from(variants);\n };\n const makeKey = (keyMethod, keyUrl, keyBody) => keyMethod.toUpperCase() + \" \" + normalizeUrl(keyUrl) + \" \" + normalizeBody(keyBody);\n const urlVariants = buildUrlVariants(url);\n const bodyValue = normalizeBody(body);\n const methodValue = (method || \"GET\").toUpperCase();\n const tryLookup = (keyMethod, keyBody) => {\n if (!byKey)\n return undefined;\n for (const urlVariant of urlVariants) {\n const record = byKey.get(makeKey(keyMethod, urlVariant, keyBody));\n if (record)\n return record;\n }\n return undefined;\n };\n const matchOrder = [\n [methodValue, bodyValue],\n [methodValue, \"\"],\n [\"GET\", \"\"],\n [\"GET\", bodyValue]\n ];\n for (const [keyMethod, keyBody] of matchOrder) {\n const record = tryLookup(keyMethod, keyBody);\n if (record)\n return record;\n }\n const urlMatches = (inputUrl, recordUrl) => {\n const inputAbs = stripHash(normalizeUrl(inputUrl));\n const recordAbs = stripHash(normalizeUrl(recordUrl));\n if (inputAbs === recordAbs)\n return true;\n const inputPathSearch = stripTrailingSlash(toPathSearch(inputUrl));\n const recordPathSearch = stripTrailingSlash(toPathSearch(recordUrl));\n if (inputPathSearch === recordPathSearch)\n return true;\n const
|
|
1
|
+
export declare const matchAPISource = "function matchAPI(options) {\n const { records, byKey, baseUrl, method, url, body } = options;\n const normalizeBody = (value) => {\n if (value === undefined || value === null)\n return \"\";\n if (typeof value === \"string\")\n return value;\n try {\n return String(value);\n }\n catch {\n return \"\";\n }\n };\n const normalizeUrl = (input) => {\n try {\n return new URL(input, baseUrl).toString();\n }\n catch {\n return input;\n }\n };\n const stripHash = (value) => {\n const index = value.indexOf(\"#\");\n return index === -1 ? value : value.slice(0, index);\n };\n const stripTrailingSlash = (value) => {\n if (value.length > 1 && value.endsWith(\"/\")) {\n return value.slice(0, -1);\n }\n return value;\n };\n const safeUrl = (input) => {\n try {\n return new URL(input, baseUrl);\n }\n catch {\n return null;\n }\n };\n const toPathSearch = (input) => {\n const parsed = safeUrl(input);\n if (!parsed)\n return input;\n return parsed.pathname + parsed.search;\n };\n const toPathname = (input) => {\n const parsed = safeUrl(input);\n return parsed ? parsed.pathname : input;\n };\n const buildUrlVariants = (input) => {\n const variants = new Set();\n const push = (value) => {\n if (!value)\n return;\n variants.add(value);\n };\n const raw = String(input ?? \"\");\n push(raw);\n push(stripHash(raw));\n push(stripTrailingSlash(raw));\n push(stripTrailingSlash(stripHash(raw)));\n const absolute = normalizeUrl(raw);\n push(absolute);\n const absoluteNoHash = stripHash(absolute);\n push(absoluteNoHash);\n push(stripTrailingSlash(absoluteNoHash));\n const pathSearch = toPathSearch(raw);\n push(pathSearch);\n push(stripTrailingSlash(pathSearch));\n const pathname = toPathname(raw);\n push(pathname);\n push(stripTrailingSlash(pathname));\n return Array.from(variants);\n };\n const makeKey = (keyMethod, keyUrl, keyBody) => keyMethod.toUpperCase() + \" \" + normalizeUrl(keyUrl) + \" \" + normalizeBody(keyBody);\n const urlVariants = buildUrlVariants(url);\n const bodyValue = normalizeBody(body);\n const methodValue = (method || \"GET\").toUpperCase();\n const tryLookup = (keyMethod, keyBody) => {\n if (!byKey)\n return undefined;\n for (const urlVariant of urlVariants) {\n const record = byKey.get(makeKey(keyMethod, urlVariant, keyBody));\n if (record)\n return record;\n }\n return undefined;\n };\n const matchOrder = [\n [methodValue, bodyValue],\n [methodValue, \"\"],\n [\"GET\", \"\"],\n [\"GET\", bodyValue]\n ];\n for (const [keyMethod, keyBody] of matchOrder) {\n const record = tryLookup(keyMethod, keyBody);\n if (record)\n return record;\n }\n const urlMatches = (inputUrl, recordUrl) => {\n const inputAbs = stripHash(normalizeUrl(inputUrl));\n const recordAbs = stripHash(normalizeUrl(recordUrl));\n if (inputAbs === recordAbs)\n return true;\n const inputPathSearch = stripTrailingSlash(toPathSearch(inputUrl));\n const recordPathSearch = stripTrailingSlash(toPathSearch(recordUrl));\n if (inputPathSearch === recordPathSearch)\n return true;\n return false;\n };\n const normalizePathLoose = (input) => {\n const parsed = safeUrl(input);\n if (parsed) {\n return stripTrailingSlash(parsed.pathname);\n }\n const rawValue = String(input ?? \"\");\n if (!rawValue) {\n return \"\";\n }\n const valueWithoutHash = rawValue.split(\"#\")[0] ?? \"\";\n const valueWithoutQuery = valueWithoutHash.split(\"?\")[0] ?? \"\";\n const withoutOrigin = valueWithoutQuery.replace(/^([a-zA-Z][a-zA-Z\\d+\\-.]*:)?\\/\\/[^/]+/, \"\");\n if (!withoutOrigin) {\n return \"/\";\n }\n return stripTrailingSlash(withoutOrigin.startsWith(\"/\") ? withoutOrigin : \"/\" + withoutOrigin);\n };\n const scanRecords = (keyMethod, keyBody) => {\n for (const record of records || []) {\n if (!record || !record.url || !record.method)\n continue;\n if (record.method.toUpperCase() !== keyMethod)\n continue;\n if (!urlMatches(url, record.url))\n continue;\n const recordBody = record.requestBody || record.requestBodyBase64 || \"\";\n if (keyBody && recordBody !== keyBody)\n continue;\n return record;\n }\n return undefined;\n };\n const scanRecordsIgnoreQueryAndProtocol = (keyMethod, keyBody) => {\n const inputPathname = normalizePathLoose(url);\n if (!inputPathname) {\n return undefined;\n }\n const candidates = [];\n for (const record of records || []) {\n if (!record || !record.url || !record.method)\n continue;\n if (record.method.toUpperCase() !== keyMethod)\n continue;\n const recordPathname = normalizePathLoose(record.url);\n if (!recordPathname || recordPathname !== inputPathname)\n continue;\n const recordBody = record.requestBody || record.requestBodyBase64 || \"\";\n if (keyBody && recordBody !== keyBody)\n continue;\n candidates.push(record);\n }\n if (candidates.length === 0)\n return undefined;\n if (candidates.length === 1) {\n const candidate = candidates[0];\n const inputParsed = safeUrl(url);\n const candidateParsed = safeUrl(candidate.url);\n if (inputParsed && candidateParsed) {\n const inputKeys = Array.from(inputParsed.searchParams.keys());\n const candidateKeys = new Set(candidateParsed.searchParams.keys());\n const inputAllContained = inputKeys.length > 0 && inputKeys.every(k => candidateKeys.has(k));\n const candidateHasExtra = candidateKeys.size > inputKeys.length;\n if (inputAllContained && candidateHasExtra)\n return undefined;\n }\n return candidate;\n }\n const inputParsed = safeUrl(url);\n const inputSearch = inputParsed ? inputParsed.search : \"\";\n let bestRecord = undefined;\n let bestScore = -Infinity;\n for (const candidate of candidates) {\n const candidateParsed = safeUrl(candidate.url);\n const candidateSearch = candidateParsed ? candidateParsed.search : \"\";\n if (candidateSearch === inputSearch)\n return candidate;\n const inputParams = inputParsed ? inputParsed.searchParams : new URLSearchParams();\n const candidateParams = candidateParsed ? candidateParsed.searchParams : new URLSearchParams();\n let shared = 0;\n let recordOnly = 0;\n for (const [key, value] of candidateParams.entries()) {\n if (inputParams.get(key) === value) {\n shared++;\n }\n else {\n recordOnly++;\n }\n }\n const score = shared - recordOnly * 2;\n if (score > bestScore) {\n bestScore = score;\n bestRecord = candidate;\n }\n }\n return bestRecord;\n };\n for (const [keyMethod, keyBody] of matchOrder) {\n const record = scanRecords(keyMethod, keyBody);\n if (record)\n return record;\n }\n for (const [keyMethod, keyBody] of matchOrder) {\n const record = scanRecordsIgnoreQueryAndProtocol(keyMethod, keyBody);\n if (record)\n return record;\n }\n return undefined;\n}";
|