feat(send): browser extension that clips pages into Readest as EPUBs (#4266)
Replaces the URL-only placeholder extension with a full MV3 page-clipper
that builds a self-contained EPUB on the user's machine and uploads it
to the inbox.
- Captures the rendered DOM in a content script, then runs Readability,
asset bundling, and EPUB build through the shared
`convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen
document (the SW lacks DOMParser).
- Uploads the resulting EPUB directly from the offscreen page to the
new `POST /api/send/inbox/file` endpoint — keeps the bytes in one
realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to
`{}` between extension contexts.
- Adds a long-lived Port + ping handshake between SW, offscreen, and
the on-demand capture content script so neither idle-eviction nor
load-order races can hang the popup.
- Localised popup, badge feedback, key-as-content i18n (`_('English source')`)
with an extract script that seeds locale stubs from i18n-langs.json
and writes a static-imports map for the runtime. All 33 locales
fully translated.
- Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body
(Content-Type: application/epub+zip), enforces the inbox pending cap,
stores to the existing send-inbox R2 bucket as `kind='file'`.
`assetBundler` now sets `credentials: 'include'` in the non-Tauri
branch so the extension SW carries paywalled-CDN cookies.
- 47 vitest cases for the extension shell (upload, badge, auth, lazy,
popup state machine, auth-bridge token sync) + 8 cases for the new
server endpoint. CI's `test_web_app` invokes both via the extended
`test:pr:web` plus a `build-browser-ext` step that catches webpack
alias / Tauri-stub regressions.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -753,6 +753,12 @@ export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const;
|
||||
export const SEND_EMAIL_DOMAIN = 'readest.com';
|
||||
export const SEND_INBOX_BUCKET = 'readest-send-inbox';
|
||||
export const SEND_INBOX_PENDING_LIMIT = 50;
|
||||
// Hard cap on the size of a single uploaded EPUB the browser extension can
|
||||
// drop into the inbox. 30 MB is the same total-asset cap the client-side
|
||||
// bundler enforces — plus a bit of head-room for chapter HTML / structural
|
||||
// overhead. Beyond this size a clipped article is almost certainly an
|
||||
// over-illustrated page that would never read well in the EPUB anyway.
|
||||
export const SEND_INBOX_FILE_MAX_BYTES = 40 * 1024 * 1024;
|
||||
export const SHARE_DEFAULT_EXPIRATION_DAYS = 3;
|
||||
export const SHARE_MAX_PER_USER = 50;
|
||||
export const SHARE_TOKEN_LENGTH = 22;
|
||||
|
||||
@@ -5,12 +5,18 @@ import type { EpubImage } from './types';
|
||||
|
||||
// On Tauri we go through the Rust HTTP client (no browser-CORS); on web
|
||||
// we use the native fetch — the bundler is only ever invoked from a
|
||||
// CORS-free caller there (the future extension's content script). In
|
||||
// Tauri we also fold in the full image-fetch header set (UA + Sec-Ch-Ua +
|
||||
// Sec-Fetch-* + Referer) so CDNs that gate images on the browser shape
|
||||
// — NYT, WSJ, paywalled CDNs — cooperate.
|
||||
// CORS-free caller there (the browser extension's service worker, which
|
||||
// has broad `host_permissions`). In the non-Tauri path we set
|
||||
// `credentials: 'include'` so the browser sends the user's cookies for
|
||||
// paywalled / member-only CDN hosts — without that, an authenticated
|
||||
// Substack image returns a placeholder. In Tauri we also fold in the
|
||||
// full image-fetch header set (UA + Sec-Ch-Ua + Sec-Fetch-* + Referer)
|
||||
// so CDNs that gate images on the browser shape — NYT, WSJ, paywalled
|
||||
// CDNs — cooperate.
|
||||
const httpFetch = (url: string, referer: string | null, init?: RequestInit): Promise<Response> => {
|
||||
if (!isTauriAppPlatform()) return globalThis.fetch(url, init);
|
||||
if (!isTauriAppPlatform()) {
|
||||
return globalThis.fetch(url, { credentials: 'include', ...init });
|
||||
}
|
||||
const baseHeaders = imageFetchHeaders(referer);
|
||||
const headers = new Headers(init?.headers);
|
||||
for (const [k, v] of Object.entries(baseHeaders)) {
|
||||
|
||||
Reference in New Issue
Block a user