Repository navigation
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves at headers time with the body as a Readable stream. New
afterResponse contract: {url, statusCode, headers, getBody()}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Binary resources are saved immediately at response time while holding the request queue slot; html/css are buffered for link rewriting and their content is freed after save. Default FS plugin pipes streams to disk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # README.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aivus
left a comment
There was a problem hiding this comment.
High-effort automated review of the v7 streaming rework (diff master...issue-386-streams). Every finding below was independently re-verified against the code before posting (17/17 candidates confirmed, 0 refuted; deduplicated to 13 distinct issues).
Severity overview, most severe first:
Correctness — streaming lifecycle
- Save failures of streamed child resources are silently swallowed (
scraper.js:228) - Live got stream leaks when
generateFilenamethrows (scraper.js:205) — reproduced live - Multiple
saveResourceactions re-buffer the whole body as a string, throwing at ~512 MiB (scraper.js:135) getBodymemoization race corrupts content under concurrent calls (request.js:66)
Correctness — undocumented v6→v7 behavior changes
5. afterResponse {body: null} now throws instead of skipping (request.js:109)
6. Default string-body encoding changed from binary to header-derived (request.js:98)
7. saveResource actions no longer run serially (scraper.js:141)
Correctness — FS plugin cleanup
8. Mid-stream failure leaves the output directory behind, breaking retries (save-resource-to-fs-plugin.js:24)
9. Cleanup rm can mask the original save error (save-resource-to-fs-plugin.js:26)
Cleanup
10. readAll duplicates stream/consumers buffer() (stream.js:1)
11. Five copy-pasted originalStream.destroy() branches (request.js:62)
12. Save failures logged as "failed to request resource" (scraper.js:238)
13. outputFile is dead code (fs.js:6)
🤖 Generated with Claude Code
- Surface save failures of streamed child resources via fatalError - Destroy response stream when generateFilename or later steps throw - Buffer multi-saveResource content as Buffer instead of latin1 string - Memoize getBody promise to make concurrent calls safe - Consolidate stream cleanup in applyAfterResponse - FS plugin: remove output directory on error, don't mask save errors - Drop readAll and outputFile in favor of stream/consumers - Document concurrent saveResource calls in MIGRATION.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Default encoding of a string body returned by afterResponse is now derived from the response content-type header instead of binary - Match utf-8 charset in content-type case-insensitively - Document the new default in README and MIGRATION.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump eslint ecmaVersion to 2025 to support it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aivus
left a comment
There was a problem hiding this comment.
Code review of the streaming rework against master. 7 findings posted inline, ranked by severity: 3 behaviour regressions vs master (fatal-error inconsistency for children, swallowed stream errors, lost mid-body retries for html/css), 3 plugin-facing edge cases, 1 cleanup.
🤖 Generated with Claude Code
| // don't rethrow - this task's rejection would be discarded by | ||
| // Promise.allSettled in downloadChildrenResources - remember the error | ||
| // instead so waitForLoad aborts the scrape | ||
| self.fatalError = self.fatalError || err; |
There was a problem hiding this comment.
Inconsistent fatal-error handling for child resources (regression vs master).
On master the body was buffered in the request phase, so a mid-body network failure on any child was a request error: handleError fired onResourceError, the rejection was discarded by Promise.allSettled in downloadChildrenResources, and the scrape completed. Only save-phase errors (fs/plugin) were fatal.
Moving body consumption for binaries into saveResource turns the same network failure into a save error, and fatalError then aborts the scrape. With default options (ignoreErrors: false), a page with 50 images where one connection is reset mid-body now fails the whole scrape and the error action deletes the output directory. Meanwhile the identical failure on an html/css child (readStreamToBuffer at line 224), or any 404/ECONNREFUSED child, is still swallowed by allSettled and the scrape completes.
The underlying issue is downloadChildrenResources ignoring rejections. Deciding once there whether child errors are fatal with ignoreErrors: false (propagate the first rejection, or consistently ignore) would remove the need for the fatalError side channel.
There was a problem hiding this comment.
Fixed in cc1efcd: a got RequestError thrown while streaming a child resource is now treated as a request error (logged, onResourceError fired, scrape continues), matching master's behaviour for children failing before the body. Storage/plugin failures and root resources failing mid-body still abort via fatalError. Added a functional test for a child dying mid-stream with ignoreErrors: false.
| } | ||
| return { | ||
| // a body error emitted before the consumer attaches its own handlers must not crash the process | ||
| stream.on('error', (err) => logger.debug(`[request] response stream error for ${response.url}: ${err.message}`)); |
There was a problem hiding this comment.
Permanent debug-only error listener can turn a failed download into a silent hang.
This listener stays attached for the life of the stream, so any consumer that does not attach its own error handler never sees the failure. A user saveResource plugin written in the v6 style from the README, e.g. await new Promise(res => resource.getContentStream().pipe(ws).on('finish', res)) (.pipe does not forward errors), will never settle when the server resets the connection mid-body: end/finish never fire, persistResource never returns, the p-queue slot is held forever and scrape() never resolves or rejects.
Without this listener the process would at least crash with an unhandled error. Better: surface the error (reject the save), or attach the listener only until a consumer takes over.
There was a problem hiding this comment.
Fixed in 704720d: persistResource now races the saveResource actions against the live stream's error event, so a body failure always fails that resource's save (and goes through handleError / onResourceError) even when the action never settles. The debug listener stays, it is still needed for the window between headers arriving and the consumer attaching. Added a functional test with a .pipe()-only plugin which previously hung the scrape.
| if (resource.isHtml() || resource.isCss()) { | ||
| // html and css need full text to find and update links to child resources, | ||
| // they are saved after their children are resolved - see Scraper.waitForLoad | ||
| resource.setText((await readStreamToBuffer(stream)).toString(responseData.encoding)); |
There was a problem hiding this comment.
html/css lose mid-body retries they had on master, with no memory benefit.
These types are still fully buffered, but they now go through got.stream and requestWithRetry removes the retry listener at response time. With request: {retry: {limit: 2}} and a server that sends headers then resets the socket during the html body, master's got() promise API retried the whole request (ReadError/ECONNRESET is retryable) and succeeded on attempt 2. Now readStreamToBuffer rejects, no retry fires, and for the root page scrape() rejects on the first attempt.
Since html/css are buffered anyway, consider the promise API with responseType: 'buffer' for them, or wrap readStreamToBuffer in a retry, so only truly streamed types lose mid-body retry.
There was a problem hiding this comment.
Fixed in 51166ee. Rather than switching html/css to the promise API (the type is only known after headers), request.get now also resolves with readBody(): it keeps got's retry listener attached while buffering, so a mid-body ReadError is retried by got under its own limit/errorCodes/backoff and the body is re-read from the retried response. The scraper uses it for html/css and afterResponse's getBody() goes through it as well. Streamed resources still consume stream directly and are not retried mid-body (documented in MIGRATION.md). Unit tests with a local server cover retry, no-retries-left, getBody, and that a streamed consumer does not retry; a functional test covers html retried while a child image is not.
| * Returns null when content was already cleared (see clearContent). | ||
| */ | ||
| getContentStream () { | ||
| if (this.contentStream) { |
There was a problem hiding this comment.
getContentStream() semantics differ by content type, so the same plugin code silently produces empty files for binaries only.
Buffered content yields a fresh stream on every call; streamed content returns the same consumed live stream. A saveResource plugin that hashes then writes (const hash = await sha256(resource.getContentStream()); await pipeline(resource.getContentStream(), ws)) works for index.html but for a.png the second call returns the already-ended got stream, pipeline writes a 0-byte file and nothing throws.
Returning null/throwing on a second call for streamed resources, or buffering on second access, would make this either visible or correct.
There was a problem hiding this comment.
Fixed in 3cf7736: a live stream is handed out once; a second getContentStream() call on a streamed resource throws Content stream of <resource> was already taken - ... instead of returning an ended stream. Buffered content (html/css text, or the buffered copy used when several saveResource actions are registered) still yields a fresh stream per call. Documented in README and MIGRATION.md, unit tests added.
| if (loadedResources.length > 0) { | ||
| // a failed save may leave directories behind even when no resource was saved - | ||
| // remove them unless the directory existed before the scrape (beforeStart threw) | ||
| if (directoryWasValidated) { |
There was a problem hiding this comment.
error action now removes the directory even when this plugin wrote nothing to it.
Master guarded the rm with loadedResources.length > 0. Now it runs whenever beforeStart passed. A user plugin that writes e.g. ${options.directory}/requests.log from beforeRequest (directory did not exist at beforeStart, so validation passed) loses that file when the first request fails with ignoreErrors: false before any resource was saved.
Tracking "this plugin created something under the directory" (a flag set in saveResource when the stream/file is opened) keeps the intended cleanup of partial saves without deleting files it did not write.
There was a problem hiding this comment.
Fixed in eff5146: the error action removes the directory only if this plugin's saveResource ran at least once (the flag is set right before outputFileStream, which is where directories get created, so a partial save is still cleaned up). A failure before any write leaves the directory and whatever other plugins put there alone, as on master. Functional test added.
| self.loadResource(resource); | ||
| try { | ||
| await self.saveResource(resource); | ||
| } catch (err) { |
There was a problem hiding this comment.
Duplicate error path: the same failure is handled and logged twice.
saveResource already catches everything and only rethrows via handleError, so this extra catch exists purely to stash fatalError. For a streamed-resource save failure with ignoreErrors: false the log shows failed to save resource, then error occurred: X from handleError, then finishing with error: X from scrape().
If child rejections are propagated in downloadChildrenResources (see the comment on line 237), persistResource's rejection can simply propagate from the queue task and this catch goes away.
There was a problem hiding this comment.
Superseded by the fix for the comment on line 237 (cc1efcd): the catch is now the place that classifies a save failure inside a request task - a got RequestError on a child is a non-fatal request error, anything else sets fatalError. So it is no longer a duplicate of saveResource's own handling. The two log lines (error occurred from handleError, finishing with error from scrape) also both appear on master for any save-phase failure, so leaving that as is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| // memoize the promise, not the value - concurrent getBody calls must not | ||
| // both start reading the same one-shot stream | ||
| let bufferedBodyPromise = null; | ||
| const getBody = () => (bufferedBodyPromise ??= readStreamToBuffer(originalStream)); |
There was a problem hiding this comment.
getBody() is still callable after the live stream has been handed to the scraper.
A plugin that stores the response object (afterResponse: ({response}) => { last = response; return {}; }) and later calls last.getBody() while the image is still streaming to disk makes readStreamToBuffer and pipeline consume originalStream concurrently. Chunks are split between them: the saved file is truncated and getBody resolves with the other half, with no error raised.
Invalidating getBody (throw/reject) once transform() has returned would make this a loud failure instead of silent corruption.
(Re-anchored after 516e7a1 moved the getBody definition; the refactor does not change this behaviour - getBody still closes over originalStream and has no guard after applyAfterResponse returns.)
There was a problem hiding this comment.
Fixed in 18a1160: getBody() now rejects with getBody() can only be called while the afterResponse action is running (<url>) once the action has returned (or thrown). Calls during the action, including concurrent ones, are unchanged. Unit test added.
Resolve conflicts with the got v16 update (#665): - pass url to got.stream separately, v16 no longer allows url in options - accept Uint8Array body from afterResponse - drop master's transformResult tests, the function no longer exists - combine both "From version 6 to 7" migration sections Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…386) Moving body consumption into the save phase made a network failure while streaming a child resource fatal with ignoreErrors: false, while the same failure on an html/css child (or any child request error) is discarded by Promise.allSettled and the scrape completes - as on master. Classify the error at the catch site: a got RequestError from a child resource is a request error and does not set fatalError; storage/plugin failures and root resource failures still abort the scrape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ettles (#386) A saveResource action which does not propagate source stream errors (e.g. .pipe() without an error handler) never resolved when the body died mid-stream: the request queue slot was held forever and scrape() hung. Race the actions against the stream's 'error' event so a failed body always fails that resource's save and goes through the normal error path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
html/css are buffered anyway, but going through got.stream with the 'retry' listener removed at response time they lost the mid-body retries the promise API gave them on master. request.get now resolves with readBody() in addition to stream: readBody keeps got's 'retry' listener attached while reading, so a transient mid-body error is retried by got (its limit, errorCodes and backoff) and the body is re-read from the retried response. The scraper uses it for html/css and afterResponse's getBody uses it too. Streamed resources keep consuming `stream` directly and are not retried mid-body, since the body is already being written to storage. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
For buffered content getContentStream() returns a fresh stream on each call, but for streamed resources it returned the same live stream, so a second call got an already-ended stream and a plugin which e.g. hashed then wrote silently produced an empty file for binaries only. A second call now throws with an explanation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#386) The error action removed the directory whenever beforeStart had validated it, so files placed there by other plugins during the run were deleted even when this plugin had not written anything. Track the first write instead (that is where directories get created), which still cleans up after a partial save. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixes #386
Reworks the scraper around streams so resource content is no longer stored in memory. Memory usage is now bounded by
requestConcurrencyinstead of total website size.Benchmark
npm run benchmark:memory(new script) — local server, 100 files × 20 MB (2 GB total), concurrency 8:How it works
lib/request.jsusesgot.streamand resolves at response-headers time with{url, statusCode, mimeType, encoding, metadata, stream, readBody}. Aretry-event wrapper keeps got's transient-error retries working for streams. Up to headers time every resource is retried; after that only buffered resources (html/css, read viareadBody()) are retried on mid-body errors — a resource which is already being piped to storage can't be handed a replacement stream, so a connection dying mid-body fails that resource.lib/scraper.js: resources which need no modification (images, fonts, media, js — the bulk of bytes) are piped straight to storage at response time, while still holding the request-queue slot sorequestConcurrencykeeps bounding concurrent transfers. HTML/CSS still need full buffering for link rewriting (cheerio re-serializes the whole document), but their text is freed immediately after save.getReferenceonly needs the child's filename, and filenames are derivable from response headers (mime → type → generateFilename) before the body is consumed.requestConcurrency: 1; covered by a regression test).RequestError) firesonResourceErrorand does not abort the scrape (same as a child failing before headers), while a storage/plugin failure or a root resource failing aborts it whenignoreErrorsis false. The save also fails when the live stream errors even if thesaveResourceaction never settles (e.g..pipe()without an error handler), so a dropped connection can't hang the scrape.resource.getContentStream()to disk and removes partially written files on mid-stream failure, soignoreErrors: truenever leaves partial garbage. Itserrorhandler removes the output directory only if the plugin itself wrote to it, so files placed there by other plugins survive a failed scrape.getContentStream()hands a live stream out only once — a second call throws instead of returning an ended stream.saveResourceactions on a streamed resource, content is buffered once (as aBuffer) so every storage can read it (a tee with in-series actions would deadlock on backpressure). Still strictly better than v6, which always buffered everything.Breaking changes (v7, documented in MIGRATION.md)
saveResourceactions:resource.getText()→resource.getContentStream()(single-use for streamed resources;await buffer(...)fromnode:stream/consumersfor whole-content storages)saveResourceactions are no longer called one resource at a time: streamed resources are saved as their responses arrive, concurrently (bounded byrequestConcurrency). Actions writing to a non-concurrency-safe sink need to serialize themselves or userequestConcurrency: 1afterResponsereceives{response: {url, statusCode, headers, getBody()}}instead of the full got response, and is called at headers time.getBody()buffers the body and is only callable while the action is running. String returns removed; returning an object withoutbodykeeps the resource streaming (zero buffering);{body: null}skips the resource like v6. A stringbodywithoutencodingis now decoded with the encoding derived fromcontent-type(utf8/binary) instead of alwaysbinaryscrape()result no longer carries content —getText()isnullafter save (also inonResourceSaved)generateFilename'sresponseDatano longer containsbodyresponseTypein therequestoption is ignoredwebsite-scraper-puppeteer / website-scraper-phantom will need mechanical updates for the new
afterResponsecontract (response.body→await response.getBody()).Testing
npm test),npm run eslintcleantest/functional/streamingsuite: byte-fidelity of multi-MB binaries, content freed after save, bothafterResponsepaths, multiplesaveResourcestorages, unconsumed streams, mid-stream failures (via a real local HTTP server — nock can't simulate a connection dying mid-body) for child/root resources with and withoutignoreErrors, asaveResourceaction which swallows stream errors, mid-body retries (html retried, streamed resource not), stream cleanup whengenerateFilenamethrows, redirect-to-already-requested-URL atrequestConcurrency: 1test/unit/plugins.test.jsforSaveResourceToFileSystemPlugin(partial-file cleanup, cleanup errors not masking the save error, output directory removed only when the plugin wrote to it)npm run test-e2e): 20+ real sites pass; the only failures areantipenko.pp.ua, which is currently stuck in a server-side 301 self-redirect loop (fails on master too)🤖 Generated with Claude Code