From d8caad329db94d23d372a681be3a252b28f36917 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:59:24 +0300 Subject: [PATCH] feat(sdk): add CDN bundle preload mode to HttpTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createContentrain({ bundle: true }) fetches _bundle/{locale}.json once and serves all content/document-index/dictionary reads from memory, collapsing per-render fetch waterfalls into a single conditional GET. Default off — non-breaking minor. Key decisions: - Bundle logic scoped to content/, documents/, meta/ paths; manifest and models/* paths never derive a locale, avoiding spurious _bundle requests - Existing fetch() body moved verbatim to _networkFetch(); per-path ETag cache, 304 handling and ContentrainError semantics unchanged, so the bundle request itself revalidates via 304 for free - Stale primed paths evicted when a fresh bundle drops them; 404/invalid/ network error → transparent per-path fallback retried after revalidateMs - revalidateMs: 0 supported (revalidate every fetch); non-i18n data.json entries resolve via defaultLocale - client.preload(locale?) for eager SSR warmup; concurrent first reads dedupe into one inflight bundle request Tests: 15 new cases in tests/cdn/bundle-preload.test.ts covering priming, out-of-scope fallback, 404 retry, 304 revalidation, eviction, version guard, data.json locale mapping, inflight dedupe, manifest guard, preload semantics and revalidateMs: 0. Full suite: 250 passing, oxlint/tsc clean. --- .changeset/query-bundle-preload.md | 12 + docs/packages/sdk.md | 30 +- packages/sdk/js/README.md | 28 +- packages/sdk/js/src/cdn/http-transport.ts | 94 +++++- packages/sdk/js/src/cdn/index.ts | 7 + .../sdk/js/tests/cdn/bundle-preload.test.ts | 303 ++++++++++++++++++ 6 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 .changeset/query-bundle-preload.md create mode 100644 packages/sdk/js/tests/cdn/bundle-preload.test.ts diff --git a/.changeset/query-bundle-preload.md b/.changeset/query-bundle-preload.md new file mode 100644 index 0000000..28c19c0 --- /dev/null +++ b/.changeset/query-bundle-preload.md @@ -0,0 +1,12 @@ +--- +"@contentrain/query": minor +--- + +feat(sdk): CDN bundle preload mode — collapse per-render fetch waterfalls into a single conditional GET + +**@contentrain/query**: `createContentrain({ bundle: true })` makes `HttpTransport` fetch `_bundle/{locale}.json` (one artifact with every JSON model for the locale) and serve all collection/singleton/dictionary/document-index reads from memory. Default off — behavior without `bundle` is unchanged. + +- **Opt-in `bundle` config.** `true` → `{ revalidateMs: 60_000 }`; the bundle is revalidated with a conditional request (unchanged → `304`) after `revalidateMs`. +- **Transparent fallback.** Bundle `404`/invalid/network error → per-path fetching (retried after `revalidateMs`), so the SDK can ship before the CDN publishes bundles. +- **Scoped coverage.** Only `content/`, `documents/`, `meta/` paths consult the bundle; document bodies, `withMeta()`, manifests, and `models/*` keep per-path fetch. Stale primed paths are evicted when a fresh bundle drops them. Non-i18n `content/{model}/data.json` entries resolve via `defaultLocale`. +- **`client.preload(locale?)`** for eager warmup (e.g. SSR boot); resolves `true` when a bundle was found. Concurrent first reads dedupe into a single bundle request. diff --git a/docs/packages/sdk.md b/docs/packages/sdk.md index a619468..4bcbd3d 100644 --- a/docs/packages/sdk.md +++ b/docs/packages/sdk.md @@ -513,6 +513,34 @@ const models = await client.models() // models/_index.json const model = await client.model('faq') // models/faq.json ``` +### Bundle Preload + +Opt-in mode that collapses per-render fetch waterfalls (N content requests + include resolution) into a single conditional GET. The transport fetches `_bundle/{locale}.json` — one artifact containing every JSON model for that locale — and serves all content, document-index, and dictionary reads from memory: + +```ts +const client = createContentrain({ + projectId: '350696e8-...', + apiKey: 'crn_live_xxx', + bundle: true, // or { revalidateMs: 30_000 } +}) + +// Optional eager warmup (e.g. SSR boot) — resolves true when a bundle was found +await client.preload('en') + +// Zero network: served from the primed bundle +const posts = await client.collection('faq').locale('en').all() +const t = await client.dictionary('ui').locale('en').get() +``` + +- **Coverage** — all JSON models (collections, singletons, dictionaries, document indexes) come from the bundle. Document bodies (`bySlug()`), entry metadata (`withMeta()`), and media manifests keep using per-path fetch. +- **Revalidation** — the bundle is re-checked after `revalidateMs` (default `60_000`) with a conditional request; an unchanged bundle answers `304` at negligible cost. +- **Fallback** — if the bundle is missing (`404`), invalid, or the request fails, the transport transparently falls back to per-path fetching and retries after `revalidateMs`. With `bundle` unset, behavior is identical to previous versions. +- **Non-i18n paths** — `content/{model}/data.json` entries ship in every locale bundle and are resolved via `defaultLocale` (default `'en'`). + +::: info Requires bundle publishing +The `_bundle/{locale}.json` artifact is produced by Studio CDN publishing. Until the project's first rebuild publishes a bundle, the SDK silently uses per-path fetching — enabling `bundle: true` early is safe. +::: + ### CDN vs Local Comparison | Aspect | Local (`#contentrain`) | CDN (`createContentrain()`) | @@ -520,7 +548,7 @@ const model = await client.model('faq') // models/faq.json | Data source | Bundled `.mjs` files | HTTP fetch from Studio CDN | | Return type | Sync (`T[]`) | Async (`Promise`) | | Auth | None | API key required | -| Caching | In-memory (embedded) | ETag-based HTTP cache | +| Caching | In-memory (embedded) | ETag-based HTTP cache + optional bundle preload | | Use case | SSG, build-time | SSR, client-side, serverless, mobile | ### Error Handling diff --git a/packages/sdk/js/README.md b/packages/sdk/js/README.md index e6d7f0f..57cf0bb 100644 --- a/packages/sdk/js/README.md +++ b/packages/sdk/js/README.md @@ -333,6 +333,32 @@ const models = await client.models() const model = await client.model('faq') ``` +### Bundle Preload + +Opt-in mode that collapses per-render fetch waterfalls (N content requests + include resolution) into a single conditional GET. The transport fetches `_bundle/{locale}.json` — one artifact containing every JSON model for that locale — and serves all content/document-index/dictionary reads from memory: + +```ts +const client = createContentrain({ + projectId: '350696e8-...', + apiKey: 'crn_live_xxx', + bundle: true, // or { revalidateMs: 30_000 } +}) + +// Optional eager warmup (e.g. SSR boot) — resolves true when a bundle was found +await client.preload('en') + +// Zero network: served from the primed bundle +const posts = await client.collection('faq').locale('en').all() +const t = await client.dictionary('ui').locale('en').get() +``` + +Behavior: + +- **Coverage** — all JSON models (collections, singletons, dictionaries, document indexes) are served from the bundle. Document bodies (`bySlug()`), entry metadata (`withMeta()`), and media manifests keep using per-path fetch. +- **Revalidation** — the bundle is re-checked after `revalidateMs` (default `60_000`) with a conditional request; an unchanged bundle answers `304` at negligible cost. +- **Fallback** — if the bundle is missing (`404`), invalid, or the request fails, the transport transparently falls back to per-path fetching and retries the bundle after `revalidateMs`. With `bundle` unset, behavior is byte-for-byte identical to previous versions. +- **Non-i18n paths** — `content/{model}/data.json` entries ship in every locale bundle and are resolved via `defaultLocale` (default `'en'`). + ### CDN vs Local | Aspect | Local (`#contentrain`) | CDN (`createContentrain()`) | @@ -340,7 +366,7 @@ const model = await client.model('faq') | Data source | Bundled `.mjs` files | HTTP fetch from CDN | | Return type | Sync (`T[]`) | Async (`Promise`) | | Auth | None | API key required | -| Caching | In-memory (embedded) | ETag-based HTTP cache | +| Caching | In-memory (embedded) | ETag-based HTTP cache + optional bundle preload | | Use case | SSG, build-time | SSR, client-side, serverless | ## CommonJS Usage diff --git a/packages/sdk/js/src/cdn/http-transport.ts b/packages/sdk/js/src/cdn/http-transport.ts index 7fcad48..ce5f83f 100644 --- a/packages/sdk/js/src/cdn/http-transport.ts +++ b/packages/sdk/js/src/cdn/http-transport.ts @@ -6,23 +6,115 @@ interface CacheEntry { etag: string } +export interface TransportConfig { + baseUrl: string + projectId: string + apiKey: string + /** Bundle preload mode. `true` → `{ revalidateMs: 60_000 }` */ + bundle?: boolean | { revalidateMs?: number } + defaultLocale?: string +} + +interface BundleState { + fetchedAt: number + paths: Set + missing: boolean + inflight: Promise | null +} + +interface BundlePayload { + version?: string + paths?: Record +} + +/** Paths eligible for bundle serving — everything else (manifests, models/*) has no locale segment. */ +const BUNDLE_SCOPE = /^(content|documents|meta)\// + export class HttpTransport { private _baseUrl: string private _projectId: string private _apiKey: string private _cache = new Map() + private _bundleMode: boolean + private _revalidateMs: number + private _defaultLocale: string + private _primed = new Map() + private _bundles = new Map() - constructor(config: { baseUrl: string; projectId: string; apiKey: string }) { + constructor(config: TransportConfig) { this._baseUrl = config.baseUrl.replace(/\/+$/, '') this._projectId = config.projectId this._apiKey = config.apiKey + const b = config.bundle + this._bundleMode = !!b + this._revalidateMs = typeof b === 'object' && typeof b.revalidateMs === 'number' ? b.revalidateMs : 60_000 + this._defaultLocale = config.defaultLocale ?? 'en' } buildUrl(path: string): string { return `${this._baseUrl}/${this._projectId}/${path}` } + /** Eager warmup (e.g. SSR boot). Resolves `true` when a bundle was found and primed. */ + async preload(locale?: string): Promise { + if (!this._bundleMode) return false + const l = locale ?? this._defaultLocale + await this._ensureBundle(l) + return !this._bundles.get(l)?.missing + } + async fetch(path: string): Promise { + if (this._bundleMode && BUNDLE_SCOPE.test(path)) { + await this._ensureBundle(this._localeOf(path)) + if (this._primed.has(path)) return this._primed.get(path) as T + // out-of-scope path (doc body, meta, ...) → regular per-path fetch + } + return this._networkFetch(path) + } + + /** Locale from a path's last segment (sans `.json`). `data` (non-i18n) → defaultLocale bundle. */ + private _localeOf(path: string): string { + const last = path.slice(path.lastIndexOf('/') + 1).replace(/\.json$/, '') + return last === 'data' ? this._defaultLocale : last + } + + private _ensureBundle(locale: string): Promise | void { + const state = this._bundles.get(locale) + if (state?.inflight) return state.inflight + if (state && Date.now() - state.fetchedAt < this._revalidateMs) return + + const next: BundleState = state ?? { fetchedAt: 0, paths: new Set(), missing: false, inflight: null } + this._bundles.set(locale, next) + next.inflight = (async () => { + try { + // _networkFetch is conditional: unchanged bundle → 304 → cached payload (cheap) + const bundle = await this._networkFetch(`_bundle/${locale}.json`) + if (bundle?.version !== '1' || !bundle.paths || typeof bundle.paths !== 'object') { + next.missing = true + return + } + // Evict primed paths absent from the fresh bundle (deleted models must not serve stale) + const fresh = new Set(Object.keys(bundle.paths)) + for (const p of next.paths) { + if (!fresh.has(p)) this._primed.delete(p) + } + for (const [p, body] of Object.entries(bundle.paths)) this._primed.set(p, body) + next.paths = fresh + next.missing = false + } + catch { + // 404 (bundle not built yet) or network error → per-path fallback until next revalidate + next.missing = true + } + finally { + next.fetchedAt = Date.now() + next.inflight = null + } + })() + return next.inflight + } + + private async _networkFetch(path: string): Promise { const url = `${this._baseUrl}/${this._projectId}/${path}` const cached = this._cache.get(path) diff --git a/packages/sdk/js/src/cdn/index.ts b/packages/sdk/js/src/cdn/index.ts index 0d30ce9..0f594d8 100644 --- a/packages/sdk/js/src/cdn/index.ts +++ b/packages/sdk/js/src/cdn/index.ts @@ -12,6 +12,8 @@ export interface ContentrainCDNConfig { apiKey: string baseUrl?: string defaultLocale?: string + /** Bundle preload mode. `true` → `{ revalidateMs: 60_000 }` */ + bundle?: boolean | { revalidateMs?: number } } export type ContentrainCDNClient = ReturnType @@ -21,6 +23,8 @@ export function createContentrain(config: ContentrainCDNConfig) { baseUrl: config.baseUrl ?? 'https://studio.contentrain.io/api/cdn/v1', projectId: config.projectId, apiKey: config.apiKey, + bundle: config.bundle, + defaultLocale: config.defaultLocale, }) const defaultLocale = config.defaultLocale @@ -54,6 +58,8 @@ export function createContentrain(config: ContentrainCDNConfig) { manifest: () => transport.fetch('_manifest.json'), models: () => transport.fetch('models/_index.json'), model: (id: string) => transport.fetch(`models/${id}.json`), + + preload: (locale?: string) => transport.preload(locale), } } @@ -61,6 +67,7 @@ export function createContentrain(config: ContentrainCDNConfig) { export { ContentrainError } from './errors.js' export type { CollectionDataSource, SingletonDataSource, DictionaryDataSource, DocumentDataSource } from './data-source.js' export { HttpTransport } from './http-transport.js' +export type { TransportConfig } from './http-transport.js' export { CdnCollectionQuery } from './collection-query.js' export { CdnSingletonAccessor } from './singleton-accessor.js' export { CdnDictionaryAccessor } from './dictionary-accessor.js' diff --git a/packages/sdk/js/tests/cdn/bundle-preload.test.ts b/packages/sdk/js/tests/cdn/bundle-preload.test.ts new file mode 100644 index 0000000..b039bbd --- /dev/null +++ b/packages/sdk/js/tests/cdn/bundle-preload.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { HttpTransport } from '../../src/cdn/http-transport.js' +import { createContentrain } from '../../src/cdn/index.js' + +interface Route { + body?: unknown + status?: number + etag?: string +} + +/** Routing mock with ETag/304 support: If-None-Match matching the route's etag → 304. */ +function routedFetch(routes: Record) { + return vi.fn().mockImplementation(async (url: string, init?: { headers?: Record }) => { + for (const [pattern, route] of Object.entries(routes)) { + if (!url.includes(pattern)) continue + if (route.etag && init?.headers?.['If-None-Match'] === route.etag) { + return { + ok: false, + status: 304, + headers: { get: () => null }, + json: () => Promise.resolve(null), + text: () => Promise.resolve(''), + } + } + const status = route.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (key: string) => (key.toLowerCase() === 'etag' ? route.etag ?? null : null) }, + json: () => Promise.resolve(route.body), + text: () => Promise.resolve(JSON.stringify(route.body)), + } + } + return { + ok: false, + status: 404, + headers: { get: () => null }, + json: () => Promise.resolve(null), + text: () => Promise.resolve('Not Found'), + } + }) +} + +function requestedUrls(mock: ReturnType): string[] { + return mock.mock.calls.map(call => call[0] as string) +} + +const BUNDLE_EN = { + version: '1', + commitSha: 'abc123', + builtAt: '2026-07-10T12:00:00.000Z', + locale: 'en', + paths: { + 'content/team/en.json': { a1: { name: 'Alice' } }, + 'content/settings/data.json': { theme: 'dark' }, + 'documents/articles/_index/en.json': [{ slug: 'intro', title: 'Intro' }], + }, +} + +describe('HttpTransport bundle preload', () => { + const config = { + baseUrl: 'https://cdn.example.com/v1', + projectId: 'proj-123', + apiKey: 'crn_live_test', + } + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('serves primed paths from the bundle with a single network call', async () => { + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const items = await transport.collection<{ id: string; name: string }>('team').getAll('en') + const index = await transport.document('articles').getIndex('en') + + expect(items).toEqual([{ id: 'a1', name: 'Alice' }]) + expect(index).toEqual([{ slug: 'intro', title: 'Intro' }]) + expect(requestedUrls(mock)).toEqual(['https://cdn.example.com/v1/proj-123/_bundle/en.json']) + }) + + it('falls back to per-path fetch for paths outside the bundle (doc bodies)', async () => { + const docBody = { frontmatter: { title: 'Intro' }, body: '# Intro', html: '

Intro

' } + const mock = routedFetch({ + '_bundle/en.json': { body: BUNDLE_EN }, + 'documents/articles/intro/en.json': { body: docBody }, + }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const doc = await transport.document('articles').getBySlug('intro', 'en') + + expect(doc).toEqual(docBody) + expect(requestedUrls(mock)).toEqual([ + 'https://cdn.example.com/v1/proj-123/_bundle/en.json', + 'https://cdn.example.com/v1/proj-123/documents/articles/intro/en.json', + ]) + }) + + it('falls back on bundle 404 and retries after revalidateMs', async () => { + vi.useFakeTimers() + const mock = routedFetch({ + 'content/team/en.json': { body: { a1: { name: 'Alice' } } }, + }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const source = transport.collection<{ id: string; name: string }>('team') + + const first = await source.getAll('en') + expect(first).toEqual([{ id: 'a1', name: 'Alice' }]) + + // Within the revalidate window: missing state is cached, bundle NOT retried + await source.getAll('en') + expect(requestedUrls(mock).filter(url => url.includes('_bundle'))).toHaveLength(1) + + // Past the window: bundle retried + vi.advanceTimersByTime(60_001) + await source.getAll('en') + expect(requestedUrls(mock).filter(url => url.includes('_bundle'))).toHaveLength(2) + }) + + it('revalidates with 304 and keeps primed data', async () => { + vi.useFakeTimers() + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN, etag: '"b1"' } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const source = transport.collection<{ id: string; name: string }>('team') + + await source.getAll('en') + vi.advanceTimersByTime(60_001) + const items = await source.getAll('en') + + expect(items).toEqual([{ id: 'a1', name: 'Alice' }]) + const urls = requestedUrls(mock) + expect(urls).toHaveLength(2) + expect(urls.every(url => url.includes('_bundle/en.json'))).toBe(true) + // Second bundle request was conditional + expect(mock.mock.calls[1][1]).toMatchObject({ + headers: expect.objectContaining({ 'If-None-Match': '"b1"' }), + }) + }) + + it('evicts primed paths removed from a fresh bundle', async () => { + vi.useFakeTimers() + const routes: Record = { + '_bundle/en.json': { + body: { version: '1', paths: { 'content/old/en.json': { source: 'bundle' } } }, + }, + 'content/old/en.json': { body: { source: 'network' } }, + } + const mock = routedFetch(routes) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const primed = await transport.fetch('content/old/en.json') + expect(primed).toEqual({ source: 'bundle' }) + + // Model deleted: fresh bundle no longer lists the path + routes['_bundle/en.json'] = { body: { version: '1', paths: {} } } + vi.advanceTimersByTime(60_001) + + const evicted = await transport.fetch('content/old/en.json') + expect(evicted).toEqual({ source: 'network' }) + }) + + it('treats unknown bundle version as missing', async () => { + const mock = routedFetch({ + '_bundle/en.json': { body: { ...BUNDLE_EN, version: '2' } }, + 'content/team/en.json': { body: { a1: { name: 'Network Alice' } } }, + }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const items = await transport.collection<{ id: string; name: string }>('team').getAll('en') + + expect(items).toEqual([{ id: 'a1', name: 'Network Alice' }]) + }) + + it('serves non-i18n data.json paths from the defaultLocale bundle', async () => { + const bundleTr = { ...BUNDLE_EN, locale: 'tr' } + const mock = routedFetch({ '_bundle/tr.json': { body: bundleTr } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true, defaultLocale: 'tr' }) + const settings = await transport.fetch('content/settings/data.json') + + expect(settings).toEqual({ theme: 'dark' }) + expect(requestedUrls(mock)).toEqual(['https://cdn.example.com/v1/proj-123/_bundle/tr.json']) + }) + + it('dedupes concurrent first fetches into a single bundle request', async () => { + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + const [items, settings, index] = await Promise.all([ + transport.fetch('content/team/en.json'), + transport.fetch('content/settings/data.json'), + transport.fetch('documents/articles/_index/en.json'), + ]) + + expect(items).toEqual({ a1: { name: 'Alice' } }) + expect(settings).toEqual({ theme: 'dark' }) + expect(index).toEqual([{ slug: 'intro', title: 'Intro' }]) + expect(requestedUrls(mock)).toEqual(['https://cdn.example.com/v1/proj-123/_bundle/en.json']) + }) + + it('never consults the bundle for manifest and model paths', async () => { + const mock = routedFetch({ + '_manifest.json': { body: { project: 'proj-123' } }, + 'models/_index.json': { body: [] }, + }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + await transport.fetch('_manifest.json') + await transport.fetch('models/_index.json') + + expect(requestedUrls(mock).some(url => url.includes('_bundle'))).toBe(false) + }) + + it('preload() returns true when the bundle exists and primes paths', async () => { + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: true }) + await expect(transport.preload('en')).resolves.toBe(true) + + await transport.fetch('content/team/en.json') + expect(requestedUrls(mock)).toEqual(['https://cdn.example.com/v1/proj-123/_bundle/en.json']) + }) + + it('preload() returns false when the bundle is missing', async () => { + vi.stubGlobal('fetch', routedFetch({})) + + const transport = new HttpTransport({ ...config, bundle: true }) + await expect(transport.preload('en')).resolves.toBe(false) + }) + + it('preload() is a no-op returning false when bundle mode is off', async () => { + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport(config) + await expect(transport.preload('en')).resolves.toBe(false) + expect(mock).not.toHaveBeenCalled() + }) + + it('revalidateMs: 0 revalidates the bundle on every fetch', async () => { + const mock = routedFetch({ '_bundle/en.json': { body: BUNDLE_EN } }) + vi.stubGlobal('fetch', mock) + + const transport = new HttpTransport({ ...config, bundle: { revalidateMs: 0 } }) + await transport.fetch('content/team/en.json') + await transport.fetch('content/team/en.json') + + expect(requestedUrls(mock).filter(url => url.includes('_bundle'))).toHaveLength(2) + }) +}) + +describe('createContentrain bundle preload', () => { + afterEach(() => { vi.restoreAllMocks() }) + + const config = { projectId: 'proj-1', apiKey: 'crn_live_test', bundle: true } + + it('serves collection queries from a preloaded bundle', async () => { + const mock = routedFetch({ + '_bundle/en.json': { + body: { + version: '1', + paths: { 'content/faq/en.json': { a1: { question: 'What?', order: 1 } } }, + }, + }, + }) + vi.stubGlobal('fetch', mock) + + const client = createContentrain(config) + await expect(client.preload('en')).resolves.toBe(true) + + const items = await client.collection('faq').locale('en').all() + expect(items).toHaveLength(1) + expect((items[0] as Record).question).toBe('What?') + expect(requestedUrls(mock)).toHaveLength(1) + }) + + it('behaves exactly like per-path mode when the bundle is unavailable', async () => { + const mock = routedFetch({ + 'content/faq/en.json': { body: { a1: { question: 'What?', order: 1 } } }, + }) + vi.stubGlobal('fetch', mock) + + const client = createContentrain(config) + const items = await client.collection('faq').locale('en').all() + + expect(items).toHaveLength(1) + expect((items[0] as Record).question).toBe('What?') + }) +})