pi-lightpanda-web 0.0.0-stage → 0.1.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/LICENSE +661 -0
- package/README.md +244 -2
- package/agent-dir.ts +19 -0
- package/cache.ts +162 -0
- package/engines.ts +865 -0
- package/entities.ts +2399 -0
- package/html-to-md.ts +1267 -0
- package/index.ts +182 -0
- package/lightpanda.ts +448 -0
- package/local-file.ts +134 -0
- package/package.json +82 -4
- package/page-text.ts +5 -0
- package/pdf-fetch.ts +405 -0
- package/pdf.ts +785 -0
- package/proxy.ts +69 -0
- package/scripts/install-lightpanda.sh +294 -0
- package/search-config.ts +240 -0
- package/settings.ts +279 -0
- package/web-access.ts +346 -0
- package/web-fetch.ts +135 -0
- package/web-search.ts +156 -0
package/README.md
CHANGED
|
@@ -1,3 +1,245 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Lightpanda Web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that provides `web_search` and
|
|
4
|
+
`web_fetch`, powered entirely by a **local [Lightpanda](https://github.com/lightpanda-io/browser)
|
|
5
|
+
browser**. It is a fork of
|
|
6
|
+
[`pi-unsloth-webtools`](https://github.com/YuGiMob/pi-unsloth-webtools): the extraction pipeline,
|
|
7
|
+
HTML→Markdown conversion, entity decoding, PDF layer, settings, and tests are derived from that
|
|
8
|
+
project, but the network stack is replaced with Lightpanda.
|
|
9
|
+
|
|
10
|
+
There is no direct Node HTTP transport for HTML, no `wreq-js` browser-fingerprint tier, and no
|
|
11
|
+
third-party rendering service. Every remote HTML page — whether fetched by `web_fetch` or used as a
|
|
12
|
+
search result page — is loaded by the local browser. PDFs are the one documented exception: a narrow
|
|
13
|
+
direct downloader fetches them for MuPDF extraction, and it can be disabled.
|
|
14
|
+
|
|
15
|
+
> The package name is **`pi-lightpanda-web`**. The npm package `pi-lightpanda` is a different,
|
|
16
|
+
> unrelated project, so this package uses the `-web` suffix to stay unique.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Node.js >= 22.19.0.
|
|
21
|
+
- A Lightpanda binary. The extension checks at `session_start` and, when no renderer is configured,
|
|
22
|
+
runs `scripts/install-lightpanda.sh` in the background; the first tool call waits for that
|
|
23
|
+
install. There is no postinstall. Set `PI_LIGHTPANDA_BIN` or `webRender.lightpandaPath` /
|
|
24
|
+
`webRender.lightpandaCommand` to use your own binary and suppress the automatic install.
|
|
25
|
+
- Optional: `mupdf` for the full PDF engine. Without it, PDF extraction falls back to a limited regular-expression extractor.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
From a checkout:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pi install /path/to/pi-lightpanda-web
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Once the package is published to npm, `pi install npm:pi-lightpanda-web` installs it under the
|
|
36
|
+
unique `-web` name. Either way, add the path (or package name) to the `packages` array in
|
|
37
|
+
`~/.pi/agent/settings.json`. From the checkout:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The extension downloads Lightpanda automatically at the next Pi startup. To pre-install it
|
|
44
|
+
yourself, run:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bash scripts/install-lightpanda.sh
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Automatic renderer installation
|
|
51
|
+
|
|
52
|
+
At `session_start`, the extension checks whether a runnable Lightpanda is already available. If
|
|
53
|
+
none is configured, it runs `scripts/install-lightpanda.sh --no-configure` in the background; the
|
|
54
|
+
first `web_fetch` or `web_search` call waits for the same install rather than racing it. There is no
|
|
55
|
+
postinstall and no bundled binary.
|
|
56
|
+
|
|
57
|
+
Explicit configuration is respected and suppresses auto-install: `PI_LIGHTPANDA_BIN`,
|
|
58
|
+
`webRender.lightpandaPath`, `webRender.lightpandaCommand`, or `webRender.lightpandaEnabled: false`.
|
|
59
|
+
The installer supports Linux (glibc; musl needs a source build), macOS, and Windows through WSL2.
|
|
60
|
+
Native Windows has no Lightpanda binary. On Linux the installer can bundle a newer glibc into the
|
|
61
|
+
install directory when the system one is too old, so Lightpanda 1.0.x runs without touching system
|
|
62
|
+
libraries.
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
Both tools show their target in the pi TUI: `web_search "query"` and `web_fetch <url>`.
|
|
67
|
+
|
|
68
|
+
### web_search
|
|
69
|
+
|
|
70
|
+
`web_search` renders each engine's result page with Lightpanda, parses the dump, and fuses the
|
|
71
|
+
results with weighted reciprocal-rank fusion (k=60):
|
|
72
|
+
|
|
73
|
+
- Default engines: **Brave** and **Ecosia**.
|
|
74
|
+
- Results are deduplicated by canonicalized href (`utm_*` and tracking parameters stripped,
|
|
75
|
+
fragments dropped, default ports removed), then capped per registrable domain
|
|
76
|
+
(`webSearch.maxPerHost`, off by default).
|
|
77
|
+
- A transient empty render is retried once inside the sweep budget. Engines still in flight are
|
|
78
|
+
aborted once enough results have arrived.
|
|
79
|
+
- `websitePolicy` is enforced before rendering and again on every result URL; when a policy
|
|
80
|
+
restricts domains, the query is scoped with rotating `site:` filters and the sweep overfetches.
|
|
81
|
+
- Failure messages are stable and documented below.
|
|
82
|
+
|
|
83
|
+
The engine set was verified live with Lightpanda 1.0.4 from a normal residential connection.
|
|
84
|
+
Lightpanda identifies itself honestly and `--user-agent` rejects any value containing `Mozilla`, so
|
|
85
|
+
some engines cannot be used:
|
|
86
|
+
|
|
87
|
+
| Engine | Status | Backend |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| Brave | Default | Brave's own independent index |
|
|
90
|
+
| Ecosia | Default | Google-provided results (each result is labelled `provider="GOOGLE"`); may answer with a 403 bot challenge |
|
|
91
|
+
| Yahoo | Opt-in | Bing's index with Yahoo's ranking; in our runs it was much better than Bing for developer queries |
|
|
92
|
+
| Bing | Opt-in | Bing's index; in our runs it returned generic entity pages for developer queries (for example `python.org` instead of the 3.14 release notes, `linux.org` instead of `io_uring` documentation) |
|
|
93
|
+
| DuckDuckGo | Unavailable | Anomaly / bot challenge |
|
|
94
|
+
| Startpage | Unavailable | Anubis proof-of-work challenge |
|
|
95
|
+
| Google | Unavailable | Consent interstitial |
|
|
96
|
+
| Mojeek | Unavailable | Captcha |
|
|
97
|
+
|
|
98
|
+
`npm run engine:eval` measures the engines against a hand-labelled developer query set. On that set:
|
|
99
|
+
top-5 URL overlap (mean Jaccard) is **0.37 Brave/Ecosia**, **0.35 Brave/Yahoo**, **0.09 Bing/Ecosia**,
|
|
100
|
+
and **0.17 Bing/Yahoo**. Brave and Ecosia are different indexes with moderate overlap; Bing's low
|
|
101
|
+
overlap comes from returning generic pages, not from adding useful coverage. Yahoo is Bing-backed but
|
|
102
|
+
ranked much better than Bing through Lightpanda, so it remains available as an opt-in fallback.
|
|
103
|
+
|
|
104
|
+
The same set exposes Bing's specificity problem. **topic@5** (top-5 results whose URL or title contains
|
|
105
|
+
a query-specific term such as `3.14`, `hnsw`, or `io_uring`) is Brave **0.97**, Yahoo **0.97**, Ecosia
|
|
106
|
+
**0.72**, and Bing **0.25**. Bing's domain-level precision (0.42) looks close to the others only because
|
|
107
|
+
broad entity pages such as `python.org` still land on an expected domain; it is not answering the query.
|
|
108
|
+
Ecosia answered 6 of 8 queries; the other two came back as `403 BotChallenge`, and the sweep falls back
|
|
109
|
+
to Brave for those. That is why the defaults are Brave + Ecosia, with Bing and Yahoo opt-in.
|
|
110
|
+
|
|
111
|
+
Use `/search-config` to enable or disable engines; Bing and Yahoo are available as opt-ins. At least one engine must stay on.
|
|
112
|
+
|
|
113
|
+
### web_fetch
|
|
114
|
+
|
|
115
|
+
- Renders the requested URL with `lightpanda fetch --json --dump html --wait-until networkidle`.
|
|
116
|
+
`--json` supplies the real HTTP status, response headers, and final URL, so errors are reported as
|
|
117
|
+
`Failed to fetch URL: HTTP 404 Not Found` instead of returning an error page as content.
|
|
118
|
+
- Converts HTML to Markdown with the ported Studio pipeline: `<article>`/`<main>` scoping,
|
|
119
|
+
hidden-element stripping, header link-density stripping, boilerplate removal, and page metadata
|
|
120
|
+
(`Title:`, `Author:`, `Date:`, `Site:`).
|
|
121
|
+
- Adds `URL:` and `Rendered via Lightpanda (local browser; nothing sent to a third party).` to every
|
|
122
|
+
rendered page.
|
|
123
|
+
- Local files are read directly (`file://` URLs, absolute paths, `~/`, `./`): HTML is converted,
|
|
124
|
+
text is returned raw, local PDFs are extracted with MuPDF, binary files are rejected. Lightpanda
|
|
125
|
+
itself does not support `file://`, so this is the only non-browser read path.
|
|
126
|
+
- Results are cached for 1 hour (128 entries, 128 MiB); a stale entry from the last 24 hours is
|
|
127
|
+
served when a render fails. The cache is never used for blocked URLs or local files.
|
|
128
|
+
- Remote PDFs are the one direct-network exception. When a URL looks like a PDF (`.pdf`, `/pdf/`,
|
|
129
|
+
or `format=pdf`) or Lightpanda reports `application/pdf`, `web_fetch` downloads it with a
|
|
130
|
+
DNS-pinned, policy-checked, redirect-validated direct request and extracts it with MuPDF. The
|
|
131
|
+
downloader has no page or byte cap, handles gzip/deflate/brotli, and sends a normal browser
|
|
132
|
+
User-Agent. Set `webFetch.allowRemotePdf: false` to disable the exception; PDFs then report
|
|
133
|
+
`(PDF content cannot be read by the local renderer)` again. The PDF downloader uses a direct
|
|
134
|
+
connection and does not apply proxy environment variables.
|
|
135
|
+
- There is no character or page cap on `web_fetch` output. HTML, local files, and PDFs are returned in
|
|
136
|
+
full; the tool does not truncate them. Only `web_search` has a result-count limit (`maxResults`).
|
|
137
|
+
|
|
138
|
+
## Local and private access
|
|
139
|
+
|
|
140
|
+
Private/loopback/link-local targets are allowed by default, so `http://localhost:3000` and LAN hosts
|
|
141
|
+
work. Lightpanda blocks unsafe ports (for example port 1) and `file://`; local files still work
|
|
142
|
+
through the fs path. Set `webFetch.allowPrivateAddresses: false` to pass
|
|
143
|
+
`--block-private-networks` to Lightpanda and restore the SSRF guard; the direct PDF downloader then
|
|
144
|
+
validates every resolved address with the same guard. Set `webFetch.allowLocalFiles: false` to refuse
|
|
145
|
+
local paths.
|
|
146
|
+
|
|
147
|
+
## Proxies
|
|
148
|
+
|
|
149
|
+
`HTTPS_PROXY`, `HTTP_PROXY`, and `ALL_PROXY` are honored when they name an **HTTP(S)** proxy: the
|
|
150
|
+
URL is passed to Lightpanda's `--http-proxy`. `NO_PROXY` exclusions are applied before launching.
|
|
151
|
+
SOCKS5 proxies (including Tor) are not supported by this fork, because Lightpanda's proxy flag is
|
|
152
|
+
HTTP-only. The direct PDF downloader is an exception to this proxy handling: it connects directly and
|
|
153
|
+
does not read proxy environment variables.
|
|
154
|
+
|
|
155
|
+
## Configuration
|
|
156
|
+
|
|
157
|
+
Settings live in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global).
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"piLightpanda": {
|
|
162
|
+
"maxResults": 5,
|
|
163
|
+
"timeoutMs": 60000,
|
|
164
|
+
"allowPrivateAddresses": true,
|
|
165
|
+
"allowLocalFiles": true,
|
|
166
|
+
"allowRemotePdf": true
|
|
167
|
+
},
|
|
168
|
+
"webFetch": {
|
|
169
|
+
"timeoutMs": 60000,
|
|
170
|
+
"allowPrivateAddresses": true,
|
|
171
|
+
"allowLocalFiles": true,
|
|
172
|
+
"allowRemotePdf": true
|
|
173
|
+
},
|
|
174
|
+
"webSearch": {
|
|
175
|
+
"engines": ["brave", "ecosia"],
|
|
176
|
+
"engineWeights": { "brave": 1, "ecosia": 1 },
|
|
177
|
+
"maxPerHost": 0
|
|
178
|
+
},
|
|
179
|
+
"webRender": {
|
|
180
|
+
"lightpandaEnabled": true,
|
|
181
|
+
"lightpandaPath": "/home/you/.local/share/pi-lightpanda-web/lightpanda/lightpanda-run"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
| Key | Default | Description |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `piLightpanda.maxResults` / `webSearch.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1–20) |
|
|
189
|
+
| `piLightpanda.timeoutMs` / `webFetch.timeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>= 1000) |
|
|
190
|
+
| `piLightpanda.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Set `false` to pass `--block-private-networks` and refuse localhost/LAN targets |
|
|
191
|
+
| `piLightpanda.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Set `false` to refuse `file://`, absolute, `~/`, and `./` paths |
|
|
192
|
+
| `piLightpanda.allowRemotePdf` / `webFetch.allowRemotePdf` | `true` | Set `false` to disable the direct PDF downloader and keep Lightpanda as the only network transport |
|
|
193
|
+
| `webSearch.engines` / `piLightpanda.engines` | `["brave","ecosia"]` | Enabled engines; unknown names are ignored, an empty match falls back to defaults |
|
|
194
|
+
| `webSearch.engineWeights` / `piLightpanda.engineWeights` | all `1` | Positive per-engine RRF weight |
|
|
195
|
+
| `webSearch.maxPerHost` / `piLightpanda.maxPerHost` | `0` (no cap) | Most results one registrable domain may contribute |
|
|
196
|
+
| `webRender.lightpandaEnabled` / `piLightpanda.lightpandaEnabled` | `true` | Disable the local renderer entirely |
|
|
197
|
+
| `webRender.lightpandaPath` / `piLightpanda.lightpandaPath` | installed launcher, else `lightpanda` on `PATH` | Path to the Lightpanda binary |
|
|
198
|
+
| `webRender.lightpandaCommand` / `piLightpanda.lightpandaCommand` | none | Command prefix, for WSL (`["wsl.exe","-e","<path>"]`) or containers; overrides `lightpandaPath` |
|
|
199
|
+
|
|
200
|
+
Environment overrides:
|
|
201
|
+
|
|
202
|
+
- `PI_LIGHTPANDA_BIN` — renderer binary path.
|
|
203
|
+
- `PI_LIGHTPANDA_CACHE_DIR` (or legacy `PI_UNSLOTH_CACHE_DIR`) — fetch cache directory.
|
|
204
|
+
- `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` — global settings directory.
|
|
205
|
+
- `PI_LIGHTPANDA_E2E=1` / `PI_LIGHTPANDA_E2E_BIN` — enable and point the live test suite.
|
|
206
|
+
|
|
207
|
+
## Troubleshooting
|
|
208
|
+
|
|
209
|
+
| Symptom | Exact string | Action |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| Empty query | `No query provided.` | Pass `query`. |
|
|
212
|
+
| Cancelled | `Search cancelled.` / `Failed to render URL: cancelled.` | Retry only if unintended. |
|
|
213
|
+
| Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
|
|
214
|
+
| No results | `No results found.` | Rephrase, or enable another engine. |
|
|
215
|
+
| Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`. |
|
|
216
|
+
| Blocked URL | `Blocked: ...` | Respect it. |
|
|
217
|
+
| Missing renderer | `(Lightpanda is not installed; run bash scripts/install-lightpanda.sh to enable web_fetch)` | Install Lightpanda or set `PI_LIGHTPANDA_BIN`. |
|
|
218
|
+
| Local render failure | `Failed to render URL: Lightpanda exited with code N.` | Check the binary by hand. |
|
|
219
|
+
| Bot challenge | `Failed to render URL: the page served a bot challenge instead of content.` | Hard anti-bot walls cannot be bypassed by Lightpanda's honest user agent. |
|
|
220
|
+
| HTTP failure | `Failed to fetch URL: HTTP 404 Not Found` | Fix the URL. |
|
|
221
|
+
| Remote PDF disabled | `(PDF content cannot be read by the local renderer)` | Set `webFetch.allowRemotePdf: true`, or use a local PDF. |
|
|
222
|
+
| Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | The render failed; output is the cached copy. |
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
npm install
|
|
228
|
+
npm run typecheck
|
|
229
|
+
npm run test:unit
|
|
230
|
+
PI_LIGHTPANDA_E2E=1 PI_LIGHTPANDA_E2E_BIN="$(bash scripts/install-lightpanda.sh --print-path)" npm run test:live
|
|
231
|
+
npm run engine:eval
|
|
232
|
+
npm run lint
|
|
233
|
+
npm run check:package
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`npm run test:unit` runs the offline suite with stubbed renderer processes. `npm run test:live` needs
|
|
237
|
+
a real Lightpanda binary and skips itself unless `PI_LIGHTPANDA_E2E=1` is set; it also exercises the
|
|
238
|
+
direct PDF downloader. CI runs both on Linux, with the unit suite also on Windows and Node
|
|
239
|
+
22.19/24/26.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
The ported logic derives from Unsloth Studio
|
|
244
|
+
([AGPL-3.0-only](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0)), so this
|
|
245
|
+
package is released under the same AGPL-3.0-only license.
|
package/agent-dir.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function expandAgentDir(value: string): string {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if (trimmed === "~") return homedir();
|
|
7
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
|
|
8
|
+
return trimmed;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function agentDir(): string {
|
|
12
|
+
const env = (process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR)?.trim();
|
|
13
|
+
if (env) return expandAgentDir(env);
|
|
14
|
+
try {
|
|
15
|
+
const home = homedir();
|
|
16
|
+
if (home) return join(home, ".pi", "agent");
|
|
17
|
+
} catch {}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
package/cache.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile, chmod } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { agentDir } from "./agent-dir.ts";
|
|
6
|
+
import { canonicalizeHref } from "./engines.ts";
|
|
7
|
+
|
|
8
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
const MAX_ENTRIES = 128;
|
|
10
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
11
|
+
const STALE_MAX_AGE_MS = 24 * CACHE_TTL_MS;
|
|
12
|
+
|
|
13
|
+
function cacheDir(): string {
|
|
14
|
+
const env = (process.env.PI_LIGHTPANDA_CACHE_DIR ?? process.env.PI_UNSLOTH_CACHE_DIR)?.trim();
|
|
15
|
+
if (env) return env;
|
|
16
|
+
const base = agentDir();
|
|
17
|
+
if (base) return join(base, "pi-lightpanda-cache");
|
|
18
|
+
return join(tmpdir(), "pi-lightpanda-cache");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cacheKey(url: string): string {
|
|
22
|
+
const canon = canonicalizeHref(url) || url;
|
|
23
|
+
return createHash("sha256").update(canon).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cachePath(key: string): string {
|
|
27
|
+
return join(cacheDir(), `${key}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function secureChmod(path: string, mode: number): Promise<void> {
|
|
31
|
+
if (process.platform === "win32") return;
|
|
32
|
+
try {
|
|
33
|
+
await chmod(path, mode);
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function unlinkSafe(path: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(path);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensureDir(): Promise<void> {
|
|
47
|
+
const dir = cacheDir();
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
await secureChmod(dir, 0o700);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CacheEntry {
|
|
55
|
+
url: string;
|
|
56
|
+
body: string;
|
|
57
|
+
contentType: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getCached(url: string): Promise<CacheEntry | null> {
|
|
62
|
+
const key = cacheKey(url);
|
|
63
|
+
const path = cachePath(key);
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(path, "utf8");
|
|
66
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
67
|
+
if (!entry || typeof entry.body !== "string" || typeof entry.contentType !== "string" || typeof entry.timestamp !== "number") return null;
|
|
68
|
+
if (!Number.isFinite(entry.timestamp)) return null;
|
|
69
|
+
return entry;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function setCached(url: string, body: string, contentType: string): Promise<void> {
|
|
76
|
+
await ensureDir();
|
|
77
|
+
const key = cacheKey(url);
|
|
78
|
+
const path = cachePath(key);
|
|
79
|
+
const entry: CacheEntry = { url: canonicalizeHref(url) || url, body, contentType, timestamp: Date.now() };
|
|
80
|
+
const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(tmpPath, JSON.stringify(entry), { mode: 0o600 });
|
|
83
|
+
await secureChmod(tmpPath, 0o600);
|
|
84
|
+
await rename(tmpPath, path);
|
|
85
|
+
await secureChmod(path, 0o600);
|
|
86
|
+
} catch {
|
|
87
|
+
await unlinkSafe(tmpPath);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await prune();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function prune(): Promise<void> {
|
|
94
|
+
const dir = cacheDir();
|
|
95
|
+
let files: string[];
|
|
96
|
+
try {
|
|
97
|
+
files = await readdir(dir);
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const tmpFiles = files.filter((f) => f.includes(".tmp."));
|
|
103
|
+
for (const file of tmpFiles) {
|
|
104
|
+
try {
|
|
105
|
+
const full = join(dir, file);
|
|
106
|
+
const s = await stat(full);
|
|
107
|
+
if (now - s.mtimeMs >= CACHE_TTL_MS) await unlinkSafe(full);
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
111
|
+
if (!jsonFiles.length) return;
|
|
112
|
+
const entries: { file: string; size: number; timestamp: number }[] = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
for (const file of jsonFiles) {
|
|
115
|
+
const full = join(dir, file);
|
|
116
|
+
try {
|
|
117
|
+
const s = await stat(full);
|
|
118
|
+
let timestamp = s.mtimeMs;
|
|
119
|
+
try {
|
|
120
|
+
const raw = await readFile(full, "utf8");
|
|
121
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
122
|
+
if (entry && typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)) timestamp = entry.timestamp;
|
|
123
|
+
} catch {}
|
|
124
|
+
if (now - timestamp >= STALE_MAX_AGE_MS) {
|
|
125
|
+
await unlinkSafe(full);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
entries.push({ file: full, size: s.size, timestamp });
|
|
129
|
+
total += s.size;
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
const expired = entries.filter((e) => now - e.timestamp >= CACHE_TTL_MS).sort((a, b) => a.timestamp - b.timestamp);
|
|
133
|
+
for (const e of expired) {
|
|
134
|
+
if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) break;
|
|
135
|
+
if (await unlinkSafe(e.file)) {
|
|
136
|
+
total -= e.size;
|
|
137
|
+
const idx = entries.indexOf(e);
|
|
138
|
+
if (idx !== -1) entries.splice(idx, 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) return;
|
|
142
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
143
|
+
let idx = 0;
|
|
144
|
+
while (idx < entries.length && (entries.length > MAX_ENTRIES || total > MAX_BYTES)) {
|
|
145
|
+
const e = entries[idx];
|
|
146
|
+
if (await unlinkSafe(e.file)) {
|
|
147
|
+
total -= e.size;
|
|
148
|
+
entries.splice(idx, 1);
|
|
149
|
+
} else {
|
|
150
|
+
idx++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isFresh(entry: CacheEntry, now = Date.now()): boolean {
|
|
156
|
+
return now - entry.timestamp < CACHE_TTL_MS;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function staleNotice(entry: CacheEntry): string {
|
|
160
|
+
const date = new Date(entry.timestamp).toISOString().slice(0, 10);
|
|
161
|
+
return `\n\n*STALE cache from ${date} — fetch failed, serving cached copy*`;
|
|
162
|
+
}
|