Skip to content

Commit 13976b3

Browse files
authored
refactor!: Centralize KVS value semantics in @crawlee/core (#3774)
1 parent 20ba48c commit 13976b3

28 files changed

Lines changed: 812 additions & 271 deletions

docs/upgrading/upgrading_v4.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,10 @@ The high-level storage classes (`Dataset`, `KeyValueStore`, `RequestQueue`) now
619619

620620
`timeoutSecs` and `doNotRetryTimeouts` were removed from `RecordOptions` (used by `KeyValueStore.setValue`). Only `contentType` remains.
621621

622+
### `maybeStringify` is removed
623+
624+
The `maybeStringify` helper exported from `@crawlee/core` has been removed. Value (de)serialization now lives entirely in the `KeyValueStore` frontend: writing serializes the value (and infers its content type), reading parses it back, and the storage client is a plain byte transport. If you imported `maybeStringify` directly, use the `serializeValue` / `parseValue` functions exported from `@crawlee/core` instead.
625+
622626
### `KeyValueStoreIteratorOptions` simplified
623627

624628
`exclusiveStartKey` and `collection` were removed. Only `prefix` remains.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@crawlee/utils": "workspace:*",
6060
"@sapphire/async-queue": "^1.5.5",
6161
"@vladfrangu/async_event_emitter": "^2.4.6",
62+
"content-type": "^1.0.5",
6263
"csv-stringify": "^6.5.2",
6364
"json5": "^2.2.3",
6465
"minimatch": "^10.0.1",

packages/core/src/storages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './dataset.js';
22
export * from './key_value_store.js';
3+
export * from './key_value_store_codec.js';
34
export * from './request_list.js';
45
export type * from './request_loader.js';
56
export type * from './request_manager.js';

packages/core/src/storages/key_value_store.ts

Lines changed: 84 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,21 @@ import JSON5 from 'json5';
66
import ow, { ArgumentError } from 'ow';
77

88
import { KEY_VALUE_STORE_KEY_REGEX } from '@apify/consts';
9-
import { jsonStringifyExtended } from '@apify/utilities';
109

1110
import { Configuration } from '../configuration.js';
1211
import { serviceLocator } from '../service_locator.js';
1312
import type { Awaitable } from '../typedefs.js';
1413
import { checkStorageAccess } from './access_checking.js';
14+
import { parseValue, serializeValue } from './key_value_store_codec.js';
1515
import type { StorageIdentifier } from './storage_instance_manager.js';
1616
import type { StorageOpenOptions } from './utils.js';
1717
import { resolveStorageIdentifier } from './storage_instance_manager.js';
1818
import { createDualIterable, purgeDefaultStorages } from './utils.js';
19+
import { isBuffer, isStream } from '@crawlee/utils';
1920

2021
/** @internal */
2122
const KVS_KEYS_DEFAULT_LIMIT = 1000;
2223

23-
/**
24-
* Helper function to possibly stringify value if options.contentType is not set.
25-
*
26-
* @ignore
27-
*/
28-
export const maybeStringify = <T>(value: T, options: { contentType?: string }) => {
29-
// If contentType is missing, value will be stringified to JSON
30-
if (options.contentType === null || options.contentType === undefined) {
31-
options.contentType = 'application/json; charset=utf-8';
32-
33-
try {
34-
// Format JSON to simplify debugging, the overheads with compression is negligible
35-
value = jsonStringifyExtended(value as Dictionary, null, 2) as unknown as T;
36-
} catch (e) {
37-
const error = e as Error;
38-
// Give more meaningful error message
39-
if (error.message?.includes('Invalid string length')) {
40-
error.message = 'Object is too large';
41-
}
42-
throw new Error(`The "value" parameter cannot be stringified to JSON: ${error.message}`);
43-
}
44-
45-
if (value === undefined) {
46-
throw new Error(
47-
'The "value" parameter was stringified to JSON and returned undefined. ' +
48-
"Make sure you're not trying to stringify an undefined value.",
49-
);
50-
}
51-
}
52-
53-
return value;
54-
};
55-
5624
/**
5725
* The `KeyValueStore` class represents a key-value store, a simple data storage that is used
5826
* for saving and reading data records or files. Each data record is
@@ -232,7 +200,53 @@ export class KeyValueStore {
232200
ow(key, ow.string.nonEmpty);
233201
const record = await this.client.getValue(key);
234202

235-
return (record?.value as T) ?? defaultValue ?? null;
203+
// A missing record falls back to the default; a record that parses to a falsy value (including
204+
// a stored literal `null`) is returned verbatim, so callers can tell "stored null" from "absent".
205+
if (!record) {
206+
return defaultValue ?? null;
207+
}
208+
209+
// Storage clients are byte transports — the value is raw bytes; the frontend parses it here.
210+
return parseValue(record.value, record.contentType ?? null) as T;
211+
}
212+
213+
/**
214+
* Reads a record from the key-value store without parsing the value.
215+
*
216+
* Use this when you need the raw bytes and the content type — for example, to run your own
217+
* parser (`simdjson`, a custom XML library, etc.) or to forward the bytes verbatim.
218+
*
219+
* There is no symmetric `setRecord` method, because {@apilink KeyValueStore.setValue} already
220+
* passes a `Buffer` (or `string` / `Stream`) through unchanged when an explicit `contentType`
221+
* is provided. To write pre-serialized bytes, call
222+
* `setValue(key, buffer, { contentType: 'application/json; charset=utf-8' })`.
223+
*
224+
* Returns `null` if the record does not exist.
225+
*
226+
* **Example usage:**
227+
* ```javascript
228+
* const store = await KeyValueStore.open();
229+
* const record = await store.getRecord('huge.json');
230+
* if (record) {
231+
* const data = simdjson.parse(record.value);
232+
* }
233+
* ```
234+
*
235+
* @param key
236+
* Unique key of the record. It can be at most 256 characters long and only consist
237+
* of the following characters: `a`-`z`, `A`-`Z`, `0`-`9` and `!-_.'()`
238+
*/
239+
async getRecord(key: string): Promise<KeyValueStoreRawRecord | null> {
240+
checkStorageAccess();
241+
242+
ow(key, ow.string.nonEmpty);
243+
const record = await this.client.getValue(key);
244+
if (!record) return null;
245+
246+
return {
247+
value: record.value,
248+
contentType: record.contentType ?? null,
249+
};
236250
}
237251

238252
/**
@@ -301,7 +315,10 @@ export class KeyValueStore {
301315
const results: T[] = [];
302316
for (const item of page) {
303317
const record = await this.client.getValue(item.key);
304-
if (record) results.push(mapRecord(item.key, record.value));
318+
if (record) {
319+
const parsed = parseValue(record.value, record.contentType ?? null);
320+
results.push(mapRecord(item.key, parsed));
321+
}
305322
}
306323
yield results;
307324
}
@@ -375,15 +392,9 @@ export class KeyValueStore {
375392
message: `The "key" argument "${key}" must be at most 256 characters long and only contain the following characters: a-zA-Z0-9!-_.'()`,
376393
})),
377394
);
378-
if (
379-
options.contentType &&
380-
!(
381-
ow.isValid(value, ow.any(ow.string, ow.uint8Array)) ||
382-
(ow.isValid(value, ow.object) && typeof (value as Dictionary).pipe === 'function')
383-
)
384-
) {
395+
if (options.contentType && !(typeof value === 'string' || isBuffer(value) || isStream(value))) {
385396
throw new ArgumentError(
386-
'The "value" parameter must be a String, Buffer or Stream when "options.contentType" is specified.',
397+
'The "value" parameter must be a String, Buffer, ArrayBuffer, TypedArray, or Stream when "options.contentType" is specified.',
387398
this.setValue,
388399
);
389400
}
@@ -417,12 +428,12 @@ export class KeyValueStore {
417428
// In this case delete the record.
418429
if (value === null) return this.client.deleteValue(key);
419430

420-
value = maybeStringify(value, optionsCopy);
431+
const serialized = serializeValue(value, optionsCopy.contentType);
421432

422433
return this.client.setValue({
423434
key,
424-
value,
425-
contentType: optionsCopy.contentType,
435+
value: serialized.value,
436+
contentType: serialized.contentType,
426437
});
427438
}
428439

@@ -745,6 +756,23 @@ export class KeyValueStore {
745756
return store.getValue<T>(key, defaultValue as T);
746757
}
747758

759+
/**
760+
* Reads a record from the default {@apilink KeyValueStore} associated with the current crawler run
761+
* without parsing the value.
762+
*
763+
* This is just a convenient shortcut for {@apilink KeyValueStore.getRecord}. Returns `null` if the
764+
* record does not exist.
765+
*
766+
* @param key
767+
* Unique key of the record. It can be at most 256 characters long and only consist
768+
* of the following characters: `a`-`z`, `A`-`Z`, `0`-`9` and `!-_.'()`
769+
* @ignore
770+
*/
771+
static async getRecord(key: string): Promise<KeyValueStoreRawRecord | null> {
772+
const store = await this.open();
773+
return store.getRecord(key);
774+
}
775+
748776
/**
749777
* Tests whether a record with the given key exists in the default {@apilink KeyValueStore} associated with the current crawler run.
750778
* @param key The queried record key.
@@ -865,6 +893,15 @@ export interface KeyValueStoreOptions {
865893
client: KeyValueStoreClient;
866894
}
867895

896+
/**
897+
* A raw, unparsed key-value store record as returned by {@apilink KeyValueStore.getRecord}: the
898+
* verbatim bytes plus the content type, with parsing left to the caller.
899+
*/
900+
export interface KeyValueStoreRawRecord {
901+
value: Buffer | ArrayBuffer;
902+
contentType: string | null;
903+
}
904+
868905
export interface RecordOptions {
869906
/**
870907
* Specifies a custom MIME content type of the record.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { Dictionary } from '@crawlee/types';
2+
import contentTypeParser from 'content-type';
3+
import { isBuffer, isStream } from '@crawlee/utils';
4+
import JSON5 from 'json5';
5+
6+
import { jsonStringifyExtended } from '@apify/utilities';
7+
8+
const CONTENT_TYPE_JSON = 'application/json';
9+
const STRINGIFIABLE_CONTENT_TYPE_RXS = [new RegExp(`^${CONTENT_TYPE_JSON}$`, 'i'), /^application\/.*xml$/i, /^text\//i];
10+
11+
/**
12+
* Canonical write path for key-value store records.
13+
*
14+
* When a content type is provided, the value passes through unchanged — it is the caller's
15+
* responsibility to supply a String/Buffer/Stream (the frontend validates this).
16+
*
17+
* When no content type is provided, it is inferred from the value's shape:
18+
* - Buffer / typed array / ArrayBuffer / stream → `application/octet-stream` (passthrough)
19+
* - `string` → `text/plain; charset=utf-8` (passthrough)
20+
* - anything else → `application/json; charset=utf-8` (serialized via `jsonStringifyExtended`)
21+
*
22+
* Does NOT drain streams — that is storage mechanics and stays in the storage client.
23+
*
24+
* Backend-independent.
25+
*/
26+
export function serializeValue(
27+
value: unknown,
28+
contentType?: string,
29+
): {
30+
value: Buffer | ArrayBuffer | ArrayBufferView | string | NodeJS.ReadableStream | ReadableStream;
31+
contentType: string;
32+
} {
33+
if (contentType !== null && contentType !== undefined) {
34+
return { value: value as Buffer | string | NodeJS.ReadableStream | ReadableStream, contentType };
35+
}
36+
37+
if (isStream(value) || isBuffer(value)) {
38+
return {
39+
value,
40+
contentType: 'application/octet-stream',
41+
};
42+
}
43+
44+
if (typeof value === 'string') {
45+
return { value, contentType: 'text/plain; charset=utf-8' };
46+
}
47+
48+
let serialized: string;
49+
try {
50+
// Format JSON to simplify debugging, the overheads with compression is negligible
51+
serialized = jsonStringifyExtended(value as Dictionary, null, 2);
52+
} catch (e) {
53+
const error = e as Error;
54+
// Give more meaningful error message
55+
if (error.message?.includes('Invalid string length')) {
56+
error.message = 'Object is too large';
57+
}
58+
throw new Error(`The "value" parameter cannot be stringified to JSON: ${error.message}`);
59+
}
60+
61+
if (serialized === undefined) {
62+
throw new Error(
63+
'The "value" parameter was stringified to JSON and returned undefined. ' +
64+
"Make sure you're not trying to stringify an undefined value.",
65+
);
66+
}
67+
68+
return { value: serialized, contentType: 'application/json; charset=utf-8' };
69+
}
70+
71+
/**
72+
* Parses a Buffer or ArrayBuffer using the provided content type header.
73+
*
74+
* - application/json is returned as a parsed object.
75+
* - application/*xml and text/* are returned as strings.
76+
* - everything else is returned as original body.
77+
*
78+
* If the header includes a charset, the body will be stringified only
79+
* if the charset represents a known encoding to Node.js or Browser.
80+
*
81+
* Backend-independent — this is the canonical read path for the {@apilink KeyValueStore} frontend.
82+
*/
83+
export function parseValue(
84+
body: Buffer | ArrayBuffer | string,
85+
contentTypeHeader: string | null,
86+
): string | Buffer | ArrayBuffer | Record<string, unknown> {
87+
// No content type at all → we have no basis for interpretation; hand back the raw value.
88+
if (contentTypeHeader === null) return body;
89+
90+
let contentType: string;
91+
let charset: BufferEncoding;
92+
try {
93+
const result = contentTypeParser.parse(contentTypeHeader);
94+
contentType = result.type;
95+
charset = result.parameters.charset as BufferEncoding;
96+
} catch {
97+
// Unparseable header → keep the original value rather than a mangled string.
98+
return body;
99+
}
100+
101+
// If we can't successfully interpret it, we return the original value rather than mangling it.
102+
if (!areDataStringifiable(contentType, charset)) return body;
103+
104+
// Decode raw bytes using the resolved charset. An already-decoded string passes through (callers
105+
// may hand us one directly), avoiding a needless re-encode round-trip.
106+
const dataString = typeof body === 'string' ? body : isomorphicBufferToString(body, charset);
107+
108+
return contentType === CONTENT_TYPE_JSON ? JSON5.parse(dataString) : dataString;
109+
}
110+
111+
function isomorphicBufferToString(buffer: Buffer | ArrayBuffer, encoding: BufferEncoding): string {
112+
if (buffer.constructor.name !== ArrayBuffer.name) {
113+
return (buffer as Buffer).toString(encoding);
114+
}
115+
116+
// In Node, wrap the ArrayBuffer in a Buffer so the resolved charset is honored (the caller already
117+
// checked it via `Buffer.isEncoding`). Only the browser, which lacks Buffer, is limited to UTF-8.
118+
if (typeof Buffer !== 'undefined') {
119+
return Buffer.from(buffer as ArrayBuffer).toString(encoding);
120+
}
121+
122+
const decoder = new TextDecoder(encoding);
123+
return decoder.decode(new Uint8Array(buffer as ArrayBuffer));
124+
}
125+
126+
function isCharsetStringifiable(charset: string): charset is BufferEncoding {
127+
if (!charset) return true; // hope that it's utf-8
128+
return Buffer.isEncoding(charset);
129+
}
130+
131+
function isContentTypeStringifiable(contentType: string): boolean {
132+
if (!contentType) return false; // keep buffer
133+
return STRINGIFIABLE_CONTENT_TYPE_RXS.some((rx) => rx.test(contentType));
134+
}
135+
136+
function areDataStringifiable(contentType: string, charset: string): boolean {
137+
return isContentTypeStringifiable(contentType) && isCharsetStringifiable(charset);
138+
}

packages/fs-storage/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
},
4444
"dependencies": {
4545
"@crawlee/types": "workspace:*",
46+
"@crawlee/utils": "workspace:*",
4647
"@sapphire/async-queue": "^1.5.5",
4748
"@sapphire/shapeshift": "^4.0.0",
4849
"content-type": "^1.0.5",

packages/fs-storage/src/fs/key-value-store/fs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ export class KeyValueFileSystemEntry implements StorageImplementation<InternalKe
3030

3131
async get(): Promise<InternalKeyRecord> {
3232
await this.fsQueue.wait();
33-
let file: Buffer | string;
33+
let file: Buffer;
3434

3535
try {
3636
file = await readFile(this.filePath);
3737
} catch {
3838
try {
3939
const noExtFilePath = resolve(this.storeDirectory, this.rawRecord.key);
40-
// Try without extension
40+
// Try without extension. Keep the raw bytes — interpretation (text vs. JSON) is the
41+
// KeyValueStore frontend's job; this client is a plain byte transport.
4142
file = await readFile(noExtFilePath);
4243
this.logger?.warning(
4344
[
@@ -47,7 +48,6 @@ export class KeyValueFileSystemEntry implements StorageImplementation<InternalKe
4748
'If you want to have correct interpretation of the file, you should add a file extension to the entry.',
4849
].join('\n'),
4950
);
50-
file = file.toString('utf-8');
5151
this.filePath = noExtFilePath;
5252
} catch {
5353
// This is impossible to happen, but just in case

0 commit comments

Comments
 (0)