From ec192367d05f8f9ec4cb3cc40f19b278a670e7fe Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 26 Jun 2024 11:43:45 -0400 Subject: [PATCH 01/16] wip oxygen cache --- packages/hydrogen/src/cache/sub-request.ts | 46 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 03ba8b2267..d06ee065f8 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -29,6 +29,8 @@ export function generateSubRequestCacheControlHeader( ); } +const CACHE_URL = 'https://oxygen.myshopify.dev'; + /** * Get an item from the cache. If a match is found, returns a tuple * containing the `JSON.parse` version of the response as well @@ -39,11 +41,27 @@ export async function getItemFromCache( cache: Cache, key: string, ): Promise { - if (!cache) return; - const url = getKeyUrl(key); - const request = new Request(url); + let response = await fetch(CACHE_URL, { + method: 'POST', + body: JSON.stringify({method: 'match', key}), + }) + .then((response) => { + return response.ok ? response : undefined; + }) + .catch((error) => { + console.error(error); + return undefined; + }); + + if (!response) { + console.debug('CACHE MATCH FALLBACK'); + + if (!cache) return; + const url = getKeyUrl(key); + const request = new Request(url); - const response = await CacheAPI.get(cache, request); + response = await CacheAPI.get(cache, request); + } if (!response) { return; @@ -67,6 +85,26 @@ export async function setItemInCache( value: any, userCacheOptions?: CachingStrategy, ) { + const result = await fetch(CACHE_URL, { + method: 'POST', + body: JSON.stringify({ + method: 'put', + key, + options: getCacheOption(userCacheOptions), + content: JSON.stringify(value), + }), + }) + .then((response) => { + return response.ok ? response : undefined; + }) + .catch((error) => { + console.error(error); + return undefined; + }); + + if (result) return; + console.debug('CACHE PUT FALLBACK'); + if (!cache) return; const url = getKeyUrl(key); From 43f6fdcd983afcfb884fd5c0639ab4a5ec5f7a8a Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 27 Jun 2024 12:16:46 -0400 Subject: [PATCH 02/16] wip oxygen cache new api --- packages/hydrogen/src/cache/run-with-cache.ts | 26 ++++--- packages/hydrogen/src/cache/sub-request.ts | 71 +++++++++++-------- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/packages/hydrogen/src/cache/run-with-cache.ts b/packages/hydrogen/src/cache/run-with-cache.ts index 88848cb104..cb2a27d912 100644 --- a/packages/hydrogen/src/cache/run-with-cache.ts +++ b/packages/hydrogen/src/cache/run-with-cache.ts @@ -4,12 +4,7 @@ import { generateCacheControlHeader, type CachingStrategy, } from './strategies'; -import { - getItemFromCache, - getKeyUrl, - isStale, - setItemInCache, -} from './sub-request'; +import {getItemFromCache, getKeyUrl, setItemInCache} from './sub-request'; import {type StackInfo} from '../utils/callsites'; import {hashKey} from '../utils/hash'; @@ -164,14 +159,15 @@ export async function runWithCache( strategy, ); - const cachedItem = await getItemFromCache(cacheInstance, key); - // console.log('--- Cache', cachedItem ? 'HIT' : 'MISS'); + const {value: cachedItem, status: cacheStatus} = + await getItemFromCache(cacheInstance, key); + // console.log('--- Cache', cacheStatus); - if (cachedItem && typeof cachedItem[0] !== 'string') { - const [{value: cachedResult, debugInfo}, cacheInfo] = cachedItem; - cachedDebugInfo = debugInfo; + if (cachedItem && cacheStatus !== 'MISS') { + cachedDebugInfo = cachedItem.debugInfo; + const cachedValue = cachedItem.value; - const cacheStatus = isStale(key, cacheInfo) ? 'STALE' : 'HIT'; + console.debug(`CACHE ${cacheStatus}`); if (!swrLock.has(key) && cacheStatus === 'STALE') { swrLock.add(key); @@ -209,13 +205,15 @@ export async function runWithCache( // Log HIT/STALE requests logSubRequestEvent?.({ - result: cachedResult, + result: cachedValue, cacheStatus, }); - return cachedResult; + return cachedValue; } + console.debug('CACHE MISS'); + const result = await actionFn({addDebugData}); // Log MISS requests diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index d06ee065f8..b2a941a78e 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -29,6 +29,7 @@ export function generateSubRequestCacheControlHeader( ); } +type CacheStatus = 'HIT' | 'MISS' | 'STALE'; const CACHE_URL = 'https://oxygen.myshopify.dev'; /** @@ -40,41 +41,55 @@ const CACHE_URL = 'https://oxygen.myshopify.dev'; export async function getItemFromCache( cache: Cache, key: string, -): Promise { - let response = await fetch(CACHE_URL, { - method: 'POST', - body: JSON.stringify({method: 'match', key}), - }) - .then((response) => { - return response.ok ? response : undefined; - }) - .catch((error) => { - console.error(error); - return undefined; +): Promise<{value?: T; status: CacheStatus}> { + try { + const originalResponse = await fetch(CACHE_URL, { + method: 'POST', + body: JSON.stringify({method: 'match', key}), }); - if (!response) { + if (!originalResponse.ok) throw new Error(originalResponse.statusText); + + const body = await originalResponse.json<{ + value: number[]; // Serialized Uint8Array + status: CacheStatus; + }>(); + + return { + value: JSON.parse(decoder.decode(new Uint8Array(body.value))), + status: body.status, + }; + } catch (error) { + console.error(error); + console.debug('CACHE MATCH FALLBACK'); - if (!cache) return; + if (!cache) return {status: 'MISS'}; + const url = getKeyUrl(key); const request = new Request(url); - response = await CacheAPI.get(cache, request); - } - - if (!response) { - return; - } - - const text = await response.text(); - try { - return [parseJSON(text), response]; - } catch { - return [text, response]; + const response = await CacheAPI.get(cache, request); + + if (!response) return {status: 'MISS'}; + + const text = await response.text(); + try { + return { + value: parseJSON(text), + status: isStale(key, response) ? 'STALE' : 'HIT', + }; + // return [parseJSON(text), response]; + } catch { + return {value: undefined, status: 'MISS'}; + // return [text, response]; + } } } +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + /** * Put an item into the cache. * @private @@ -91,15 +106,15 @@ export async function setItemInCache( method: 'put', key, options: getCacheOption(userCacheOptions), - content: JSON.stringify(value), + value: encoder.encode(JSON.stringify(value)), }), }) .then((response) => { - return response.ok ? response : undefined; + return response.ok; }) .catch((error) => { console.error(error); - return undefined; + return false; }); if (result) return; From e08afa8a9ea06414a462ff7d8bd68d06ec3d5e43 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 27 Jun 2024 15:23:19 -0400 Subject: [PATCH 03/16] fix for undefined values --- packages/hydrogen/src/cache/sub-request.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index b2a941a78e..fe325833e0 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -51,12 +51,14 @@ export async function getItemFromCache( if (!originalResponse.ok) throw new Error(originalResponse.statusText); const body = await originalResponse.json<{ - value: number[]; // Serialized Uint8Array + value?: number[]; // Serialized Uint8Array status: CacheStatus; }>(); return { - value: JSON.parse(decoder.decode(new Uint8Array(body.value))), + value: body.value + ? JSON.parse(decoder.decode(new Uint8Array(body.value))) + : undefined, status: body.status, }; } catch (error) { From a7b6c923ad010cb065a22138fbfac1266d0eacf6 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 27 Jun 2024 15:25:51 -0400 Subject: [PATCH 04/16] crypto hash key --- packages/hydrogen/src/cache/run-with-cache.ts | 9 ++++++++- packages/hydrogen/src/cache/sub-request.ts | 6 +++--- packages/hydrogen/src/utils/hash.ts | 17 +++++++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/hydrogen/src/cache/run-with-cache.ts b/packages/hydrogen/src/cache/run-with-cache.ts index cb2a27d912..5a71cf0738 100644 --- a/packages/hydrogen/src/cache/run-with-cache.ts +++ b/packages/hydrogen/src/cache/run-with-cache.ts @@ -72,11 +72,18 @@ export async function runWithCache( }: WithCacheOptions, ): Promise { const startTime = Date.now(); - const key = hashKey([ + const key = await hashKey([ // '__HYDROGEN_CACHE_ID__', // TODO purgeQueryCacheOnBuild ...(typeof cacheKey === 'string' ? [cacheKey] : cacheKey), ]); + // console.debug(key, { + // original: (Array.isArray(cacheKey) ? cacheKey.join(':') : cacheKey).slice( + // 170, + // 270, + // ), + // }); + let cachedDebugInfo: CachedDebugInfo | undefined; let userDebugInfo: CachedDebugInfo | undefined; diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index fe325833e0..7a3a30bcee 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -14,7 +14,7 @@ import { * Cache API is weird. We just need a full URL, so we make one up. */ export function getKeyUrl(key: string) { - return `https://shopify.dev/?${key}`; + return `https://shopify.dev/?${encodeURIComponent(key)}`; } function getCacheOption(userCacheOptions?: CachingStrategy): AllCacheOptions { @@ -64,7 +64,7 @@ export async function getItemFromCache( } catch (error) { console.error(error); - console.debug('CACHE MATCH FALLBACK'); + console.debug('CACHE:MATCH FALLBACK'); if (!cache) return {status: 'MISS'}; @@ -120,7 +120,7 @@ export async function setItemInCache( }); if (result) return; - console.debug('CACHE PUT FALLBACK'); + console.debug('CACHE:PUT FALLBACK'); if (!cache) return; diff --git a/packages/hydrogen/src/utils/hash.ts b/packages/hydrogen/src/utils/hash.ts index a34994f855..fdf2ea46c5 100644 --- a/packages/hydrogen/src/utils/hash.ts +++ b/packages/hydrogen/src/utils/hash.ts @@ -1,6 +1,8 @@ type QueryKey = string | readonly unknown[]; -export function hashKey(queryKey: QueryKey): string { +const encoder = new TextEncoder(); + +export async function hashKey(queryKey: QueryKey): Promise { const rawKeys = Array.isArray(queryKey) ? queryKey : [queryKey]; let hash = ''; @@ -21,5 +23,16 @@ export function hashKey(queryKey: QueryKey): string { } } - return encodeURIComponent(hash); + const hashBuffer = await crypto.subtle.digest( + 'sha-512', + encoder.encode(hash), + ); + + // Hex string + // return Array.from(new Uint8Array(hashBuffer)) + // .map((byte) => byte.toString(16).padStart(2, '0')) + // .join(''); + + // B64 string + return btoa(String.fromCharCode(...new Uint8Array(hashBuffer))); } From 0393e9947d635c3663ac036271922350ffdc1710 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 27 Jun 2024 15:52:24 -0400 Subject: [PATCH 05/16] Serialize byte array properly --- packages/hydrogen/src/cache/sub-request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 7a3a30bcee..5ccbb95872 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -108,7 +108,7 @@ export async function setItemInCache( method: 'put', key, options: getCacheOption(userCacheOptions), - value: encoder.encode(JSON.stringify(value)), + value: Object.values(encoder.encode(JSON.stringify(value))), }), }) .then((response) => { From 0795948594683e9ac734152c38fc257216d49895 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 28 Jun 2024 11:26:41 -0400 Subject: [PATCH 06/16] Add cacheKey param to storefront client --- packages/hydrogen/src/storefront.ts | 38 ++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index 6e26b16d33..f119749ac3 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -10,6 +10,7 @@ import { } from '@shopify/hydrogen-react'; import type {WritableDeep} from 'type-fest'; import {fetchWithServerCache, checkGraphQLErrors} from './cache/server-fetch'; +import type {CacheKey} from './cache/run-with-cache'; import { SDK_VARIANT_HEADER, SDK_VARIANT_SOURCE_HEADER, @@ -117,7 +118,8 @@ export type Storefront = { ...options: ClientVariablesInRestParams< StorefrontQueries, RawGqlString, - StorefrontCommonExtraParams & Pick, + StorefrontCommonExtraParams & + Pick, AutoAddedVariableNames > ) => Promise< @@ -192,13 +194,22 @@ type StorefrontHeaders = { type StorefrontQueryOptions = StorefrontCommonExtraParams & { query: string; mutation?: never; + /** + * Cache strategy. + */ cache?: CachingStrategy; + /** + * Unique key used for caching. Specify it only if you want + * to be able to manually invalidate by cache key later. + */ + cacheKey?: CacheKey; }; type StorefrontMutationOptions = StorefrontCommonExtraParams & { query?: never; mutation: string; cache?: never; + cacheKey?: never; }; const defaultI18n: I18nBase = {language: 'EN', country: 'US'}; @@ -280,6 +291,7 @@ export function createStorefrontClient( storefrontApiVersion, displayName, stackInfo, + cacheKey, }: {variables?: GenericVariables; stackInfo?: StackInfo} & ( | StorefrontQueryOptions | StorefrontMutationOptions @@ -315,18 +327,22 @@ export function createStorefrontClient( body: graphqlData, } satisfies RequestInit; - const cacheKey = [ - url, - requestInit.method, - cacheKeyHeader, - requestInit.body, - ]; + const cacheParams = mutation + ? undefined + : { + cacheInstance: cache, + cache: cacheOptions ?? CacheDefault(), + cacheKey: cacheKey ?? [ + url, + requestInit.method, + cacheKeyHeader, + requestInit.body, + ], + shouldCacheResponse: checkGraphQLErrors, + }; const [body, response] = await fetchWithServerCache(url, requestInit, { - cacheInstance: mutation ? undefined : cache, - cache: cacheOptions || CacheDefault(), - cacheKey, - shouldCacheResponse: checkGraphQLErrors, + ...cacheParams, waitUntil, debugInfo: { requestId: requestInit.headers[STOREFRONT_REQUEST_GROUP_ID_HEADER], From ce78e94c39d952076569b9fbb37c93e5ca5bab5f Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 3 Jul 2024 15:35:51 +0900 Subject: [PATCH 07/16] Add cacheTags param to storefront client --- packages/hydrogen/src/cache/run-with-cache.ts | 4 ++++ packages/hydrogen/src/cache/server-fetch.ts | 3 +++ packages/hydrogen/src/cache/sub-request.ts | 2 ++ packages/hydrogen/src/storefront.ts | 8 ++++++++ packages/hydrogen/src/vite/request-events.ts | 1 + 5 files changed, 18 insertions(+) diff --git a/packages/hydrogen/src/cache/run-with-cache.ts b/packages/hydrogen/src/cache/run-with-cache.ts index 5a71cf0738..fdab15f608 100644 --- a/packages/hydrogen/src/cache/run-with-cache.ts +++ b/packages/hydrogen/src/cache/run-with-cache.ts @@ -47,6 +47,7 @@ type WithCacheOptions = { shouldCacheResult?: (value: T) => boolean; waitUntil?: ExecutionContext['waitUntil']; debugInfo?: DebugOptions; + cacheTags?: string[]; }; // Lock to prevent revalidating the same sub-request @@ -69,6 +70,7 @@ export async function runWithCache( shouldCacheResult = () => true, waitUntil, debugInfo, + cacheTags, }: WithCacheOptions, ): Promise { const startTime = Date.now(); @@ -136,6 +138,7 @@ export async function runWithCache( status: cacheStatus, strategy: generateCacheControlHeader(strategy || {}), key, + tags: cacheTags, }, waitUntil, }); @@ -164,6 +167,7 @@ export async function runWithCache( process.env.NODE_ENV === 'development' ? mergeDebugInfo() : undefined, } satisfies CachedItem, strategy, + cacheTags, ); const {value: cachedItem, status: cacheStatus} = diff --git a/packages/hydrogen/src/cache/server-fetch.ts b/packages/hydrogen/src/cache/server-fetch.ts index b24c574b5f..b1d3fc9484 100644 --- a/packages/hydrogen/src/cache/server-fetch.ts +++ b/packages/hydrogen/src/cache/server-fetch.ts @@ -9,6 +9,7 @@ export type FetchCacheOptions = { cache?: CachingStrategy; cacheInstance?: Cache; cacheKey?: CacheKey; + cacheTags?: string[]; shouldCacheResponse?: (body: any, response: Response) => boolean; waitUntil?: ExecutionContext['waitUntil']; returnType?: 'json' | 'text' | 'arrayBuffer' | 'blob'; @@ -47,6 +48,7 @@ export async function fetchWithServerCache( cacheInstance, cache: cacheOptions, cacheKey = [url, requestInit], + cacheTags, shouldCacheResponse = () => true, waitUntil, returnType = 'json', @@ -80,6 +82,7 @@ export async function fetchWithServerCache( cacheInstance, waitUntil, strategy: cacheOptions ?? null, + cacheTags, debugInfo, shouldCacheResult: (result) => shouldCacheResponse(...fromSerializableResponse(result)), diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 5ccbb95872..9c028e8278 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -101,12 +101,14 @@ export async function setItemInCache( key: string, value: any, userCacheOptions?: CachingStrategy, + tags?: string[], ) { const result = await fetch(CACHE_URL, { method: 'POST', body: JSON.stringify({ method: 'put', key, + tags, options: getCacheOption(userCacheOptions), value: Object.values(encoder.encode(JSON.stringify(value))), }), diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index f119749ac3..2a1c7671fc 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -203,6 +203,11 @@ type StorefrontQueryOptions = StorefrontCommonExtraParams & { * to be able to manually invalidate by cache key later. */ cacheKey?: CacheKey; + /** + * Tags to associate with this query. Useful to invalidate + * the cache by specifying one of the tags. + */ + cacheTags?: string[]; }; type StorefrontMutationOptions = StorefrontCommonExtraParams & { @@ -210,6 +215,7 @@ type StorefrontMutationOptions = StorefrontCommonExtraParams & { mutation: string; cache?: never; cacheKey?: never; + cacheTags?: never; }; const defaultI18n: I18nBase = {language: 'EN', country: 'US'}; @@ -292,6 +298,7 @@ export function createStorefrontClient( displayName, stackInfo, cacheKey, + cacheTags, }: {variables?: GenericVariables; stackInfo?: StackInfo} & ( | StorefrontQueryOptions | StorefrontMutationOptions @@ -338,6 +345,7 @@ export function createStorefrontClient( cacheKeyHeader, requestInit.body, ], + cacheTags, shouldCacheResponse: checkGraphQLErrors, }; diff --git a/packages/hydrogen/src/vite/request-events.ts b/packages/hydrogen/src/vite/request-events.ts index daa3642f1f..1233a375a2 100644 --- a/packages/hydrogen/src/vite/request-events.ts +++ b/packages/hydrogen/src/vite/request-events.ts @@ -43,6 +43,7 @@ export type RequestEventPayload = { status?: string; strategy?: string; key?: string | readonly unknown[]; + tags?: string[]; }; displayName?: string; }; From f5fb64d8323f09e5fe929c34de150ba87b3f3da2 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 17 Jul 2024 13:41:13 +0900 Subject: [PATCH 08/16] Minor fixes --- package-lock.json | 6 +++--- packages/hydrogen/src/cache/sub-request.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6af5edd6b7..9c8419e454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29700,7 +29700,7 @@ }, "packages/cli": { "name": "@shopify/cli-hydrogen", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT", "dependencies": { "@ast-grep/napi": "0.11.0", @@ -29935,7 +29935,7 @@ }, "packages/create-hydrogen": { "name": "@shopify/create-hydrogen", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { "@ast-grep/napi": "0.11.0" @@ -32394,7 +32394,7 @@ } }, "templates/skeleton": { - "version": "2024.7.1", + "version": "2024.7.2", "dependencies": { "@remix-run/react": "^2.10.1", "@remix-run/server-runtime": "^2.10.1", diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 9c028e8278..95273ec242 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -50,10 +50,10 @@ export async function getItemFromCache( if (!originalResponse.ok) throw new Error(originalResponse.statusText); - const body = await originalResponse.json<{ + const body: { value?: number[]; // Serialized Uint8Array status: CacheStatus; - }>(); + } = await originalResponse.json(); return { value: body.value From 8e94a0cf921b24bcf8fb8726824554ddd98716f0 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 17 Jul 2024 16:39:02 +0900 Subject: [PATCH 09/16] Refactor oxygen plugin --- packages/mini-oxygen/src/vite/plugin.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/mini-oxygen/src/vite/plugin.ts b/packages/mini-oxygen/src/vite/plugin.ts index b46c3dc4e5..dd678e63e0 100644 --- a/packages/mini-oxygen/src/vite/plugin.ts +++ b/packages/mini-oxygen/src/vite/plugin.ts @@ -37,6 +37,7 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { let resolvedConfig: ResolvedConfig; let absoluteWorkerEntryFile: string; let apiOptions: OxygenApiOptions = {}; + let entry = pluginOptions?.entry ?? DEFAULT_SSR_ENTRY; return [ { @@ -61,7 +62,7 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { config.build?.ssr === true ? // No --entry flag passed by the user, use the // option passed to the plugin or the default value - pluginOptions.entry ?? DEFAULT_SSR_ENTRY + entry : // --entry flag passed by the user, keep it config.build?.ssr, }, @@ -81,18 +82,19 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { }; }, }, - configureServer: { - order: 'pre', - handler: (viteDevServer) => { - const entry = - apiOptions.entry ?? pluginOptions.entry ?? DEFAULT_SSR_ENTRY; - - // For transform hook: - resolvedConfig = viteDevServer.config; + configResolved: { + order: 'post', + handler(config) { + entry = apiOptions.entry ?? pluginOptions?.entry ?? DEFAULT_SSR_ENTRY; + resolvedConfig = config; absoluteWorkerEntryFile = path.isAbsolute(entry) ? entry : path.resolve(resolvedConfig.root, entry); - + }, + }, + configureServer: { + order: 'pre', + handler(viteDevServer) { return () => { setupOxygenMiddleware(viteDevServer, async () => { const remoteEnv = await Promise.resolve(apiOptions.envPromise); From e125def1bde53435a0aff7197ffd239ef67ea8bc Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 17 Jul 2024 16:39:45 +0900 Subject: [PATCH 10/16] Add dummy unstable cache polyfill --- packages/mini-oxygen/package.json | 6 +++++- packages/mini-oxygen/src/cache/index.ts | 6 ++++++ packages/mini-oxygen/src/cache/polyfill.ts | 5 +++++ packages/mini-oxygen/src/vite/plugin.ts | 20 ++++++++++++++------ templates/skeleton/vite.config.ts | 2 +- 5 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 packages/mini-oxygen/src/cache/index.ts create mode 100644 packages/mini-oxygen/src/cache/polyfill.ts diff --git a/packages/mini-oxygen/package.json b/packages/mini-oxygen/package.json index 434a9d28c5..c05d3606d9 100644 --- a/packages/mini-oxygen/package.json +++ b/packages/mini-oxygen/package.json @@ -11,7 +11,7 @@ "main": "./dist/worker/index.js", "module": "dist/worker/index.js", "types": "./dist/worker/index.d.ts", - "sideEffects": false, + "sideEffects": true, "repository": "https://github.com/Shopify/hydrogen.git", "scripts": { "build": "tsup", @@ -39,6 +39,10 @@ "types": "./dist/vite/plugin.d.ts", "default": "./dist/vite/plugin.js" }, + "./unstable-cache-polyfill": { + "types": "./dist/cache/polyfill.d.ts", + "default": "./dist/cache/polyfill.js" + }, "./package.json": "./package.json" }, "dependencies": { diff --git a/packages/mini-oxygen/src/cache/index.ts b/packages/mini-oxygen/src/cache/index.ts new file mode 100644 index 0000000000..30c82cb61d --- /dev/null +++ b/packages/mini-oxygen/src/cache/index.ts @@ -0,0 +1,6 @@ +const originalCachesOpen = caches.open.bind(caches); + +export async function createOxygenCache(cacheName: string) { + const cacheInstance = await originalCachesOpen(cacheName); + return cacheInstance; +} diff --git a/packages/mini-oxygen/src/cache/polyfill.ts b/packages/mini-oxygen/src/cache/polyfill.ts new file mode 100644 index 0000000000..ffad3ea791 --- /dev/null +++ b/packages/mini-oxygen/src/cache/polyfill.ts @@ -0,0 +1,5 @@ +import {createOxygenCache} from './index.js'; + +globalThis.caches.open = (cacheName) => { + return createOxygenCache(cacheName); +}; diff --git a/packages/mini-oxygen/src/vite/plugin.ts b/packages/mini-oxygen/src/vite/plugin.ts index dd678e63e0..859f4ece39 100644 --- a/packages/mini-oxygen/src/vite/plugin.ts +++ b/packages/mini-oxygen/src/vite/plugin.ts @@ -13,7 +13,7 @@ export type OxygenPluginOptions = Partial< Pick< MiniOxygenViteOptions, 'entry' | 'env' | 'inspectorPort' | 'logRequestLine' | 'debug' - > + > & {unstableCache: boolean} >; type OxygenApiOptions = OxygenPluginOptions & @@ -120,17 +120,25 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { }, transform(code, id, options) { if ( - resolvedConfig?.command === 'serve' && - resolvedConfig?.server?.hmr !== false && options?.ssr && (id === absoluteWorkerEntryFile || id === absoluteWorkerEntryFile + path.extname(id)) ) { - return { + if (pluginOptions?.unstableCache) { + code = + `import '@shopify/mini-oxygen/unstable-cache-polyfill';` + code; + } + + if ( + resolvedConfig?.command === 'serve' && + resolvedConfig?.server?.hmr !== false + ) { // Accept HMR in server entry module to avoid full-page refresh in the browser. // Note: appending code at the end should not break the source map. - code: code + '\nif (import.meta.hot) import.meta.hot.accept();', - }; + code = code + '\nif (import.meta.hot) import.meta.hot.accept();'; + } + + return {code}; } }, } satisfies OxygenPlugin, diff --git a/templates/skeleton/vite.config.ts b/templates/skeleton/vite.config.ts index f2da56a0a9..82b435a2a0 100644 --- a/templates/skeleton/vite.config.ts +++ b/templates/skeleton/vite.config.ts @@ -7,7 +7,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [ hydrogen(), - oxygen(), + oxygen({unstableCache: true}), remix({ presets: [hydrogen.preset()], future: { From 878d2c3f707f446291258b513bc3afcb796ad70f Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 18 Jul 2024 20:08:26 +0900 Subject: [PATCH 11/16] Outbound worker for Oxygen Cache API in MiniOxygen --- packages/hydrogen/src/cache/api.ts | 37 +++++--- packages/hydrogen/src/cache/sub-request.ts | 30 +++--- packages/mini-oxygen/src/cache/common.ts | 1 + packages/mini-oxygen/src/cache/index.ts | 6 -- .../src/cache/node-outbound-handler.ts | 86 ++++++++++++++++++ packages/mini-oxygen/src/cache/polyfill.ts | 6 +- packages/mini-oxygen/src/cache/worker-api.ts | 10 ++ packages/mini-oxygen/src/vite/plugin.ts | 12 ++- .../mini-oxygen/src/vite/server-middleware.ts | 3 + packages/mini-oxygen/src/worker/index.ts | 91 +++++++++++++------ templates/skeleton/vite.config.ts | 2 +- 11 files changed, 214 insertions(+), 70 deletions(-) create mode 100644 packages/mini-oxygen/src/cache/common.ts delete mode 100644 packages/mini-oxygen/src/cache/index.ts create mode 100644 packages/mini-oxygen/src/cache/node-outbound-handler.ts create mode 100644 packages/mini-oxygen/src/cache/worker-api.ts diff --git a/packages/hydrogen/src/cache/api.ts b/packages/hydrogen/src/cache/api.ts index 59ec22be6d..3289cdb46b 100644 --- a/packages/hydrogen/src/cache/api.ts +++ b/packages/hydrogen/src/cache/api.ts @@ -67,17 +67,7 @@ async function getItem( return response; } -/** - * Put an item into the cache. - */ -async function setItem( - cache: Cache, - request: Request, - response: Response, - userCacheOptions: CachingStrategy, -) { - if (!cache) return; - +function getCacheControlHeaders(userCacheOptions: CachingStrategy) { /** * We are manually managing staled request by adding this workaround. * Why? cache control header support is dependent on hosting platform @@ -129,9 +119,26 @@ async function setItem( // CF will override cache-control, so we need to keep a non-modified real-cache-control // cache-control is still necessary for mini-oxygen - response.headers.set('cache-control', paddedCacheControlString); - response.headers.set('real-cache-control', cacheControlString); - response.headers.set('cache-put-date', String(Date.now())); + return [ + ['cache-control', paddedCacheControlString], + ['real-cache-control', cacheControlString], + ['cache-put-date', String(Date.now())], + ]; +} +/** + * Put an item into the cache. + */ +async function setItem( + cache: Cache, + request: Request, + response: Response, + userCacheOptions: CachingStrategy, +) { + if (!cache) return; + + for (const [key, value] of getCacheControlHeaders(userCacheOptions)) { + response.headers.set(key, value); + } logCacheApiStatus('PUT', request, response); await cache.put(request, response); @@ -187,6 +194,6 @@ export const CacheAPI = { get: getItem, set: setItem, delete: deleteItem, - generateDefaultCacheControlHeader, + getCacheControlHeaders, isStale, }; diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 95273ec242..6798f51a34 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -21,16 +21,8 @@ function getCacheOption(userCacheOptions?: CachingStrategy): AllCacheOptions { return userCacheOptions || CacheDefault(); } -export function generateSubRequestCacheControlHeader( - userCacheOptions?: CachingStrategy, -): string { - return CacheAPI.generateDefaultCacheControlHeader( - getCacheOption(userCacheOptions), - ); -} - type CacheStatus = 'HIT' | 'MISS' | 'STALE'; -const CACHE_URL = 'https://oxygen.myshopify.dev'; +const CACHE_URL = 'https://oxygen.myshopify.dev/cache'; /** * Get an item from the cache. If a match is found, returns a tuple @@ -45,10 +37,14 @@ export async function getItemFromCache( try { const originalResponse = await fetch(CACHE_URL, { method: 'POST', - body: JSON.stringify({method: 'match', key}), + body: JSON.stringify({ + name: 'hydrogen', + method: 'match', + key, + }), }); - if (!originalResponse.ok) throw new Error(originalResponse.statusText); + if (!originalResponse.ok) throw new Error(await originalResponse.text()); const body: { value?: number[]; // Serialized Uint8Array @@ -59,7 +55,7 @@ export async function getItemFromCache( value: body.value ? JSON.parse(decoder.decode(new Uint8Array(body.value))) : undefined, - status: body.status, + status: isStale(key, originalResponse) ? 'STALE' : body.status, }; } catch (error) { console.error(error); @@ -78,8 +74,8 @@ export async function getItemFromCache( const text = await response.text(); try { return { - value: parseJSON(text), - status: isStale(key, response) ? 'STALE' : 'HIT', + value: text ? parseJSON(text) : undefined, + status: text ? (isStale(key, response) ? 'STALE' : 'HIT') : 'MISS', }; // return [parseJSON(text), response]; } catch { @@ -106,11 +102,15 @@ export async function setItemInCache( const result = await fetch(CACHE_URL, { method: 'POST', body: JSON.stringify({ + name: 'hydrogen', method: 'put', key, tags, - options: getCacheOption(userCacheOptions), + // options: getCacheOption(userCacheOptions), value: Object.values(encoder.encode(JSON.stringify(value))), + headers: CacheAPI.getCacheControlHeaders( + getCacheOption(userCacheOptions), + ), }), }) .then((response) => { diff --git a/packages/mini-oxygen/src/cache/common.ts b/packages/mini-oxygen/src/cache/common.ts new file mode 100644 index 0000000000..2b4a59dd1a --- /dev/null +++ b/packages/mini-oxygen/src/cache/common.ts @@ -0,0 +1 @@ +export const OXYGEN_CACHE_URL = 'https://oxygen.myshopify.dev/cache'; diff --git a/packages/mini-oxygen/src/cache/index.ts b/packages/mini-oxygen/src/cache/index.ts deleted file mode 100644 index 30c82cb61d..0000000000 --- a/packages/mini-oxygen/src/cache/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -const originalCachesOpen = caches.open.bind(caches); - -export async function createOxygenCache(cacheName: string) { - const cacheInstance = await originalCachesOpen(cacheName); - return cacheInstance; -} diff --git a/packages/mini-oxygen/src/cache/node-outbound-handler.ts b/packages/mini-oxygen/src/cache/node-outbound-handler.ts new file mode 100644 index 0000000000..8375d11bfe --- /dev/null +++ b/packages/mini-oxygen/src/cache/node-outbound-handler.ts @@ -0,0 +1,86 @@ +// This code runs on Node environment +import {Request, Response, type Miniflare} from 'miniflare'; +import {OXYGEN_CACHE_URL} from './common.js'; + +export function isCacheRequest(request: Request) { + return request.url === OXYGEN_CACHE_URL; +} + +type GlobalCaches = {open: (cacheName: string) => Promise}; +type FetchResponse = InstanceType; + +// When Miniflare reloads, we need to recreate the stubs. +const openedCachesMap = new Map(); +export function resetBindingStubs() { + openedCachesMap.clear(); +} + +type OxygenCachePayload = { + name: string; + key: string; + method: 'put' | 'match' | 'delete'; + headers: Array<[string, string]>; + value: number[]; +}; + +export async function handleOutboundCacheRequest( + request: Request, + mf: Miniflare, +) { + let tBody = Date.now(); + const body = (await request.json()) as OxygenCachePayload; + tBody = Date.now() - tBody; + let tGetCaches = Date.now(); + let cacheInstance = openedCachesMap.get(body.name); + + if (!cacheInstance) { + const caches = (await mf.getCaches()) as unknown as GlobalCaches; + + tGetCaches = Date.now() - tGetCaches; + var tCachesOpen = Date.now(); + cacheInstance = await caches.open(body.name); + tCachesOpen = Date.now() - tCachesOpen; + openedCachesMap.set(body.name, cacheInstance); + } else { + tGetCaches = Date.now() - tGetCaches; + } + + const cacheKey = new Request(getKeyUrl(body.key)) as unknown as RequestInfo; + + try { + if (body.method === 'match') { + const cacheResponse = (await cacheInstance.match( + cacheKey, + )) as unknown as Response; + + return new Response( + JSON.stringify({ + value: cacheResponse + ? Object.values(new Uint8Array(await cacheResponse?.arrayBuffer())) + : undefined, + status: cacheResponse ? 'HIT' : 'MISS', + }), + cacheResponse, + ); + } else if (body.method === 'put') { + const cacheValue = new Response(new Uint8Array(body.value), { + headers: body.headers, + }) as unknown as FetchResponse; + + await cacheInstance.put(cacheKey, cacheValue); + return new Response(); + } else if (body.method === 'delete') { + await cacheInstance.delete(cacheKey); + return new Response(); + } else { + throw new Error(`cache.${body.method} is not implemented`); + } + } catch (error) { + console.error(error); + return new Response((error as Error).message, {status: 500}); + } +} + +function getKeyUrl(key: string) { + return `https://shopify.dev/?${encodeURIComponent(key)}`; +} diff --git a/packages/mini-oxygen/src/cache/polyfill.ts b/packages/mini-oxygen/src/cache/polyfill.ts index ffad3ea791..611ae36a47 100644 --- a/packages/mini-oxygen/src/cache/polyfill.ts +++ b/packages/mini-oxygen/src/cache/polyfill.ts @@ -1,5 +1,3 @@ -import {createOxygenCache} from './index.js'; +import {createOxygenCache} from './worker-api.js'; -globalThis.caches.open = (cacheName) => { - return createOxygenCache(cacheName); -}; +globalThis.caches.open = createOxygenCache; diff --git a/packages/mini-oxygen/src/cache/worker-api.ts b/packages/mini-oxygen/src/cache/worker-api.ts new file mode 100644 index 0000000000..ceb253c950 --- /dev/null +++ b/packages/mini-oxygen/src/cache/worker-api.ts @@ -0,0 +1,10 @@ +// This code runs on worker environment +const originalCachesOpen = globalThis.caches?.open.bind(globalThis.caches); + +export async function createOxygenCache(cacheName: string) { + const cacheInstance = await originalCachesOpen(cacheName); + // TODO ensure methods meet these conditions: + // https://developers.cloudflare.com/workers/runtime-apis/cache/#methods + + return cacheInstance; +} diff --git a/packages/mini-oxygen/src/vite/plugin.ts b/packages/mini-oxygen/src/vite/plugin.ts index 859f4ece39..e9fa9036c1 100644 --- a/packages/mini-oxygen/src/vite/plugin.ts +++ b/packages/mini-oxygen/src/vite/plugin.ts @@ -12,8 +12,13 @@ const DEFAULT_SSR_ENTRY = './server'; export type OxygenPluginOptions = Partial< Pick< MiniOxygenViteOptions, - 'entry' | 'env' | 'inspectorPort' | 'logRequestLine' | 'debug' - > & {unstableCache: boolean} + | 'entry' + | 'env' + | 'inspectorPort' + | 'logRequestLine' + | 'debug' + | 'unstableOxygenCache' + > >; type OxygenApiOptions = OxygenPluginOptions & @@ -109,6 +114,7 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { apiOptions.inspectorPort ?? pluginOptions.inspectorPort, requestHook: apiOptions.requestHook, entryPointErrorHandler: apiOptions.entryPointErrorHandler, + unstableOxygenCache: pluginOptions?.unstableOxygenCache, logRequestLine: // Give priority to the plugin option over the CLI option here, // since the CLI one is just a default, not a user-provided flag. @@ -124,7 +130,7 @@ export function oxygen(pluginOptions: OxygenPluginOptions = {}): Plugin[] { (id === absoluteWorkerEntryFile || id === absoluteWorkerEntryFile + path.extname(id)) ) { - if (pluginOptions?.unstableCache) { + if (pluginOptions?.unstableOxygenCache) { code = `import '@shopify/mini-oxygen/unstable-cache-polyfill';` + code; } diff --git a/packages/mini-oxygen/src/vite/server-middleware.ts b/packages/mini-oxygen/src/vite/server-middleware.ts index 76f559f824..0890c704f7 100644 --- a/packages/mini-oxygen/src/vite/server-middleware.ts +++ b/packages/mini-oxygen/src/vite/server-middleware.ts @@ -66,6 +66,7 @@ export type MiniOxygenViteOptions = InternalMiniOxygenOptions & { debug?: boolean; inspectorPort?: number; logRequestLine?: null | RequestHook; + unstableOxygenCache?: boolean; }; type MiniOxygen = ReturnType; @@ -79,6 +80,7 @@ function startMiniOxygenRuntime({ entry: workerEntryFile, requestHook, logRequestLine = defaultLogRequestLine, + unstableOxygenCache, }: MiniOxygenViteOptions) { const wrappedHook = requestHook || logRequestLine @@ -94,6 +96,7 @@ function startMiniOxygenRuntime({ const miniOxygen = createMiniOxygen({ debug, inspectorPort, + unstableOxygenCache, requestHook: null, workers: [ { diff --git a/packages/mini-oxygen/src/worker/index.ts b/packages/mini-oxygen/src/worker/index.ts index 06701b6e4c..e6120273c1 100644 --- a/packages/mini-oxygen/src/worker/index.ts +++ b/packages/mini-oxygen/src/worker/index.ts @@ -26,6 +26,11 @@ import {findPort} from '../common/find-port.js'; import {OXYGEN_COMPAT_PARAMS} from '../common/compat.js'; import {isO2Verbose} from '../common/debug.js'; import type {OnlyBindings, OnlyServices} from './utils.js'; +import { + isCacheRequest, + handleOutboundCacheRequest, + resetBindingStubs, +} from '../cache/node-outbound-handler.js'; export { buildAssetsUrl, @@ -72,6 +77,7 @@ export type MiniOxygenOptions = InputMiniflareOptions & { assets?: AssetOptions; requestHook?: RequestHook | null; inspectWorkerName?: string; + unstableOxygenCache?: boolean; }; export type MiniOxygenInstance = ReturnType; @@ -83,10 +89,15 @@ export function createMiniOxygen({ sourceMapPath = '', requestHook, inspectWorkerName, + unstableOxygenCache = false, ...miniflareOptions }: MiniOxygenOptions) { const mf = new Miniflare( - buildMiniflareOptions(miniflareOptions, requestHook, assets), + buildMiniflareOptions(miniflareOptions, { + requestHook, + assets, + unstableOxygenCache, + }), ); if (!sourceMapPath) { @@ -117,6 +128,11 @@ export function createMiniOxygen({ : undefined, ]); + if (unstableOxygenCache) { + // Warmup + mf.getCaches().catch(() => {}); + } + reconnect = createInspectorConnector({ sourceMapPath, publicInspectorPort, @@ -162,19 +178,26 @@ export function createMiniOxygen({ }); await reconnect(() => - mf.setOptions( - buildMiniflareOptions( - {...miniflareOptions, ...newOptions}, - requestHook, - assets, - ), - ), + mf + .setOptions( + buildMiniflareOptions( + {...miniflareOptions, ...newOptions}, + {requestHook, assets, unstableOxygenCache}, + ), + ) + .then(resetBindingStubs), ); + + if (unstableOxygenCache) { + // Warmup + mf.getCaches().catch(() => {}); + } }, async dispose() { assetsServer?.closeAllConnections(); assetsServer?.close(); await mf.dispose(); + resetBindingStubs(); isDisposed = true; }, get isDisposed() { @@ -204,23 +227,16 @@ const oxygenHeadersMap = Object.values(OXYGEN_HEADERS_MAP).reduce( {} as Record, ); -// Opt-out of TLS validation in the worker environment, -// and run network requests in Node environment. -// https://nodejs.org/api/cli.html#node_tls_reject_unauthorizedvalue -const UNSAFE_OUTBOUND_SERVICE = { - async outboundService(request: Request) { - const response = await fetch(request.url, request); - // Remove brotli encoding: - // https://github.com/cloudflare/workers-sdk/issues/5345 - response.headers.delete('Content-Encoding'); - return response; - }, -}; - function buildMiniflareOptions( {workers, ...mfOverwriteOptions}: InputMiniflareOptions, - requestHook: RequestHook | null = defaultLogRequestLine, - assetsOptions?: AssetOptions, + { + requestHook = defaultLogRequestLine, + assets: assetsOptions, + unstableOxygenCache, + }: Pick< + MiniOxygenOptions, + 'requestHook' | 'assets' | 'unstableOxygenCache' + > = {}, ): OutputMiniflareOptions { const entryWorker = workers.find((worker) => !!worker.name); if (!entryWorker?.name) { @@ -290,13 +306,36 @@ function buildMiniflareOptions( }, ...workers.map((worker) => { const isNormalWorker = !wrappedBindings.has(worker.name); - const useUnsafeOutboundService = - isNormalWorker && process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'; + const useOutboundWorker = + isNormalWorker && + (!!unstableOxygenCache || + // Opt-out of TLS validation in the worker environment, + // and run network requests in Node environment. + // https://nodejs.org/api/cli.html#node_tls_reject_unauthorizedvalue + process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'); return { ...(isNormalWorker && OXYGEN_COMPAT_PARAMS), - ...(useUnsafeOutboundService && UNSAFE_OUTBOUND_SERVICE), ...worker, + ...(useOutboundWorker && { + async outboundService(request: Request, mf: Miniflare) { + if (isCacheRequest(request)) { + return handleOutboundCacheRequest(request, mf); + } + + if (typeof worker.outboundService === 'function') { + return worker.outboundService(request, mf); + } + + const response = await fetch(request.url, request); + + // Remove brotli encoding: + // https://github.com/cloudflare/workers-sdk/issues/5345 + response.headers.delete('Content-Encoding'); + + return response; + }, + }), }; }), ], diff --git a/templates/skeleton/vite.config.ts b/templates/skeleton/vite.config.ts index 82b435a2a0..810ff09cef 100644 --- a/templates/skeleton/vite.config.ts +++ b/templates/skeleton/vite.config.ts @@ -7,7 +7,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [ hydrogen(), - oxygen({unstableCache: true}), + oxygen({unstableOxygenCache: true}), remix({ presets: [hydrogen.preset()], future: { From 509f440eedc6dc1ddd5c663fba79fadca7de088f Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 19 Jul 2024 00:39:30 +0900 Subject: [PATCH 12/16] Global Cache stub for Oxygen Cache API in MiniOxygen --- packages/hydrogen/src/cache/sub-request.ts | 87 ++--------- packages/mini-oxygen/src/cache/common.ts | 16 ++ .../src/cache/node-outbound-handler.ts | 58 +++---- packages/mini-oxygen/src/cache/swr.ts | 42 +++++ packages/mini-oxygen/src/cache/worker-api.ts | 147 +++++++++++++++++- 5 files changed, 241 insertions(+), 109 deletions(-) create mode 100644 packages/mini-oxygen/src/cache/swr.ts diff --git a/packages/hydrogen/src/cache/sub-request.ts b/packages/hydrogen/src/cache/sub-request.ts index 6798f51a34..47d0cceec0 100644 --- a/packages/hydrogen/src/cache/sub-request.ts +++ b/packages/hydrogen/src/cache/sub-request.ts @@ -22,7 +22,6 @@ function getCacheOption(userCacheOptions?: CachingStrategy): AllCacheOptions { } type CacheStatus = 'HIT' | 'MISS' | 'STALE'; -const CACHE_URL = 'https://oxygen.myshopify.dev/cache'; /** * Get an item from the cache. If a match is found, returns a tuple @@ -34,60 +33,29 @@ export async function getItemFromCache( cache: Cache, key: string, ): Promise<{value?: T; status: CacheStatus}> { - try { - const originalResponse = await fetch(CACHE_URL, { - method: 'POST', - body: JSON.stringify({ - name: 'hydrogen', - method: 'match', - key, - }), - }); - - if (!originalResponse.ok) throw new Error(await originalResponse.text()); - - const body: { - value?: number[]; // Serialized Uint8Array - status: CacheStatus; - } = await originalResponse.json(); - - return { - value: body.value - ? JSON.parse(decoder.decode(new Uint8Array(body.value))) - : undefined, - status: isStale(key, originalResponse) ? 'STALE' : body.status, - }; - } catch (error) { - console.error(error); + if (!cache) return {status: 'MISS'}; - console.debug('CACHE:MATCH FALLBACK'); - - if (!cache) return {status: 'MISS'}; - - const url = getKeyUrl(key); - const request = new Request(url); + const url = getKeyUrl(key); + const request = new Request(url); - const response = await CacheAPI.get(cache, request); + const response = await CacheAPI.get(cache, request); - if (!response) return {status: 'MISS'}; + if (!response) return {status: 'MISS'}; + try { const text = await response.text(); - try { - return { - value: text ? parseJSON(text) : undefined, - status: text ? (isStale(key, response) ? 'STALE' : 'HIT') : 'MISS', - }; - // return [parseJSON(text), response]; - } catch { - return {value: undefined, status: 'MISS'}; - // return [text, response]; - } + return { + value: text ? parseJSON(text) : undefined, + status: text + ? (response.headers.get('oxygen-cache-status') as null | CacheStatus) ?? + (isStale(key, response) ? 'STALE' : 'HIT') + : 'MISS', + }; + } catch { + return {value: undefined, status: 'MISS'}; } } -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - /** * Put an item into the cache. * @private @@ -99,31 +67,6 @@ export async function setItemInCache( userCacheOptions?: CachingStrategy, tags?: string[], ) { - const result = await fetch(CACHE_URL, { - method: 'POST', - body: JSON.stringify({ - name: 'hydrogen', - method: 'put', - key, - tags, - // options: getCacheOption(userCacheOptions), - value: Object.values(encoder.encode(JSON.stringify(value))), - headers: CacheAPI.getCacheControlHeaders( - getCacheOption(userCacheOptions), - ), - }), - }) - .then((response) => { - return response.ok; - }) - .catch((error) => { - console.error(error); - return false; - }); - - if (result) return; - console.debug('CACHE:PUT FALLBACK'); - if (!cache) return; const url = getKeyUrl(key); diff --git a/packages/mini-oxygen/src/cache/common.ts b/packages/mini-oxygen/src/cache/common.ts index 2b4a59dd1a..cfa9f2591f 100644 --- a/packages/mini-oxygen/src/cache/common.ts +++ b/packages/mini-oxygen/src/cache/common.ts @@ -1 +1,17 @@ export const OXYGEN_CACHE_URL = 'https://oxygen.myshopify.dev/cache'; + +export const CACHE_CONTROL = 'cache-control'; +export const REAL_CACHE_CONTROL = 'real-cache-control'; +export const CACHE_PUT_DATE = 'cache-put-date'; + +export type CacheStatus = 'HIT' | 'MISS' | 'STALE'; +export type OxygenCacheMatchResponse = {value?: number[]; status: CacheStatus}; + +export type OxygenCachePayload = { + name: string; + key: string; +} & ( + | {method: 'match'} + | {method: 'delete'} + | {method: 'put'; value: number[]; headers: Array<[string, string]>} +); diff --git a/packages/mini-oxygen/src/cache/node-outbound-handler.ts b/packages/mini-oxygen/src/cache/node-outbound-handler.ts index 8375d11bfe..0af7b4f8cf 100644 --- a/packages/mini-oxygen/src/cache/node-outbound-handler.ts +++ b/packages/mini-oxygen/src/cache/node-outbound-handler.ts @@ -1,6 +1,11 @@ // This code runs on Node environment import {Request, Response, type Miniflare} from 'miniflare'; -import {OXYGEN_CACHE_URL} from './common.js'; +import { + OXYGEN_CACHE_URL, + type OxygenCachePayload, + type OxygenCacheMatchResponse, +} from './common.js'; +import {addSwrHeaders, isStale} from './swr.js'; export function isCacheRequest(request: Request) { return request.url === OXYGEN_CACHE_URL; @@ -15,37 +20,24 @@ export function resetBindingStubs() { openedCachesMap.clear(); } -type OxygenCachePayload = { - name: string; - key: string; - method: 'put' | 'match' | 'delete'; - headers: Array<[string, string]>; - value: number[]; -}; - export async function handleOutboundCacheRequest( request: Request, mf: Miniflare, ) { - let tBody = Date.now(); const body = (await request.json()) as OxygenCachePayload; - tBody = Date.now() - tBody; - let tGetCaches = Date.now(); let cacheInstance = openedCachesMap.get(body.name); if (!cacheInstance) { const caches = (await mf.getCaches()) as unknown as GlobalCaches; - - tGetCaches = Date.now() - tGetCaches; - var tCachesOpen = Date.now(); cacheInstance = await caches.open(body.name); - tCachesOpen = Date.now() - tCachesOpen; openedCachesMap.set(body.name, cacheInstance); - } else { - tGetCaches = Date.now() - tGetCaches; } - const cacheKey = new Request(getKeyUrl(body.key)) as unknown as RequestInfo; + const cacheKey = new Request( + `https://shopify.dev/?cache_name=${encodeURIComponent( + body.name, + )}&cache_key=${encodeURIComponent(body.key)}`, + ) as unknown as RequestInfo; try { if (body.method === 'match') { @@ -53,34 +45,32 @@ export async function handleOutboundCacheRequest( cacheKey, )) as unknown as Response; - return new Response( - JSON.stringify({ - value: cacheResponse - ? Object.values(new Uint8Array(await cacheResponse?.arrayBuffer())) - : undefined, - status: cacheResponse ? 'HIT' : 'MISS', - }), + return Response.json( + (cacheResponse + ? { + status: isStale(cacheResponse) ? 'STALE' : 'HIT', + value: Object.values( + new Uint8Array(await cacheResponse?.arrayBuffer()), + ), + } + : {status: 'MISS'}) satisfies OxygenCacheMatchResponse, cacheResponse, ); } else if (body.method === 'put') { const cacheValue = new Response(new Uint8Array(body.value), { - headers: body.headers, + headers: addSwrHeaders(body.headers), }) as unknown as FetchResponse; await cacheInstance.put(cacheKey, cacheValue); return new Response(); } else if (body.method === 'delete') { - await cacheInstance.delete(cacheKey); - return new Response(); + const isRemoved = await cacheInstance.delete(cacheKey); + return Response.json(isRemoved); } else { - throw new Error(`cache.${body.method} is not implemented`); + throw new Error(`cache.${(body as any).method} is not implemented`); } } catch (error) { console.error(error); return new Response((error as Error).message, {status: 500}); } } - -function getKeyUrl(key: string) { - return `https://shopify.dev/?${encodeURIComponent(key)}`; -} diff --git a/packages/mini-oxygen/src/cache/swr.ts b/packages/mini-oxygen/src/cache/swr.ts new file mode 100644 index 0000000000..b6d1dd9901 --- /dev/null +++ b/packages/mini-oxygen/src/cache/swr.ts @@ -0,0 +1,42 @@ +import type {Response} from 'miniflare'; +import {CACHE_CONTROL, REAL_CACHE_CONTROL, CACHE_PUT_DATE} from './common.js'; + +export function addSwrHeaders(originalHeaders: Array<[string, string]>) { + const headers = new Headers(originalHeaders); + const cacheControlHeader = headers.get(CACHE_CONTROL); + + if (!cacheControlHeader) return headers; + + const maxAge = Number( + cacheControlHeader.match(/(?:^|,)\s*max-age=(\d+)/)?.[1] ?? 0, + ); + const swr = Number( + cacheControlHeader.match(/(?:^|,)\s*stale-while-revalidate=(\d+)/)?.[1] ?? + 0, + ); + + if (!maxAge || !swr) return headers; + + const paddedCacheControlHeader = cacheControlHeader.replace( + /((?:^|,)\s*max-age)=\d+/, + `$1=${maxAge + swr}`, + ); + + headers.set(CACHE_CONTROL, paddedCacheControlHeader); + headers.set(REAL_CACHE_CONTROL, cacheControlHeader); + headers.set(CACHE_PUT_DATE, String(Date.now())); + + return [...headers]; +} + +export function isStale(response: Response) { + const responseDate = response.headers.get(CACHE_PUT_DATE); + const realCacheControl = response.headers.get(REAL_CACHE_CONTROL); + + if (!responseDate || !realCacheControl) return false; + + const age = (Date.now() - Number(responseDate)) / 1000; + const maxAge = Number(realCacheControl.match(/max-age=(\d+)/)?.[1] ?? 0); + + return age > maxAge; +} diff --git a/packages/mini-oxygen/src/cache/worker-api.ts b/packages/mini-oxygen/src/cache/worker-api.ts index ceb253c950..9cf274d34a 100644 --- a/packages/mini-oxygen/src/cache/worker-api.ts +++ b/packages/mini-oxygen/src/cache/worker-api.ts @@ -1,10 +1,151 @@ // This code runs on worker environment -const originalCachesOpen = globalThis.caches?.open.bind(globalThis.caches); +import { + OXYGEN_CACHE_URL, + CACHE_CONTROL, + REAL_CACHE_CONTROL, + CACHE_PUT_DATE, + type OxygenCacheMatchResponse, + type OxygenCachePayload, +} from './common.js'; export async function createOxygenCache(cacheName: string) { - const cacheInstance = await originalCachesOpen(cacheName); // TODO ensure methods meet these conditions: // https://developers.cloudflare.com/workers/runtime-apis/cache/#methods - return cacheInstance; + return new OxygenCache(cacheName); +} + +function notImplementedMessage(methodName: string) { + // Same message as native CF cache: + return `Failed to execute '${methodName}' on 'Cache': the method is not implemented.`; +} + +async function cacheFetch(body: OxygenCachePayload) { + try { + const response = await fetch(OXYGEN_CACHE_URL, { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error(response.statusText); + + return response; + } catch (unknownError) { + const error = unknownError as Error; + error.message = `[o2:error:cache.${body.method}] ` + error.message; + console.error(error); + } +} + +export class OxygenCache implements Cache { + #cacheName: string; + + constructor(cacheName: string) { + this.#cacheName = cacheName; + } + + keys(request?: Request): Promise { + throw new Error(notImplementedMessage('keys')); + } + + add(request: RequestInfo): Promise { + throw new Error(notImplementedMessage('add')); + } + + addAll(requests: RequestInfo[]): Promise { + throw new Error(notImplementedMessage('addAll')); + } + + matchAll( + request?: RequestInfo, + options?: CacheQueryOptions, + ): Promise { + throw new Error(notImplementedMessage('matchAll')); + } + + async put(request: Request, response: Response) { + if (request.method !== 'GET') { + throw new TypeError('Cannot cache response to non-GET request.'); + } + + if (response.status === 206) { + throw new TypeError( + 'Cannot cache response to a range request (206 Partial Content).', + ); + } + + if (response.headers.get('vary')?.includes('*')) { + throw new TypeError("Cannot cache response with 'Vary: *' header."); + } + + const headers = new Headers(response.headers); + + // Hydrogen might send this header + const realCacheControl = headers.get(REAL_CACHE_CONTROL); + if (realCacheControl) { + headers.set(CACHE_CONTROL, realCacheControl); + } + + if (headers.get(CACHE_CONTROL)?.includes('public') === false) { + // cache.put returns a 413 error if Cache-Control instructs not to cache or if the response is too large. + new Response('Content Too Large', {status: 413}); + } + + // Hydrogen might send these headers, clean it up + headers.delete(REAL_CACHE_CONTROL); + headers.delete(CACHE_PUT_DATE); + + // TODO support tags + await cacheFetch({ + name: this.#cacheName, + method: 'put', + key: request.url, + // tags, + // options: getCacheOption(userCacheOptions), + value: Object.values(new Uint8Array(await response.arrayBuffer())), + headers: [...headers], + }); + } + + async match(request: Request) { + if (request.method !== 'GET') return; + + const cacheResponse = await cacheFetch({ + name: this.#cacheName, + method: 'match', + key: request.url, + }); + + if (!cacheResponse) { + return; + } + + try { + const {value, status} = + await cacheResponse.json(); + + if (!value || status === 'MISS') return; + + return new Response(new Uint8Array(value), { + status: cacheResponse.status ?? 200, + headers: [...cacheResponse.headers, ['oxygen-cache-status', status]], + }); + } catch (unknownError) { + const error = unknownError as Error; + error.message = `[o2:error:cache.match] ` + error.message; + console.error(error); + return; + } + } + + async delete(request: Request) { + const cacheResponse = await cacheFetch({ + name: this.#cacheName, + method: 'delete', + key: request.url, + // tags, + }); + + return cacheResponse ? await cacheResponse.json() : false; + } } From db783e5bf2d935c0684e0c6bc67c54b3b55dcb25 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 19 Jul 2024 12:18:54 +0900 Subject: [PATCH 13/16] Small refactor --- .../src/cache/node-outbound-handler.ts | 12 ++--- packages/mini-oxygen/src/cache/worker-api.ts | 44 +++++++++---------- packages/mini-oxygen/src/worker/index.ts | 6 +-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/mini-oxygen/src/cache/node-outbound-handler.ts b/packages/mini-oxygen/src/cache/node-outbound-handler.ts index 0af7b4f8cf..d6fb0e1fd8 100644 --- a/packages/mini-oxygen/src/cache/node-outbound-handler.ts +++ b/packages/mini-oxygen/src/cache/node-outbound-handler.ts @@ -14,10 +14,10 @@ export function isCacheRequest(request: Request) { type GlobalCaches = {open: (cacheName: string) => Promise}; type FetchResponse = InstanceType; -// When Miniflare reloads, we need to recreate the stubs. -const openedCachesMap = new Map(); -export function resetBindingStubs() { - openedCachesMap.clear(); +// When Miniflare reloads, we need to reset these in-memory resources. +const activeCacheInstances = new Map(); +export function releaseNodeCacheResources() { + activeCacheInstances.clear(); } export async function handleOutboundCacheRequest( @@ -25,12 +25,12 @@ export async function handleOutboundCacheRequest( mf: Miniflare, ) { const body = (await request.json()) as OxygenCachePayload; - let cacheInstance = openedCachesMap.get(body.name); + let cacheInstance = activeCacheInstances.get(body.name); if (!cacheInstance) { const caches = (await mf.getCaches()) as unknown as GlobalCaches; cacheInstance = await caches.open(body.name); - openedCachesMap.set(body.name, cacheInstance); + activeCacheInstances.set(body.name, cacheInstance); } const cacheKey = new Request( diff --git a/packages/mini-oxygen/src/cache/worker-api.ts b/packages/mini-oxygen/src/cache/worker-api.ts index 9cf274d34a..4e990928ec 100644 --- a/packages/mini-oxygen/src/cache/worker-api.ts +++ b/packages/mini-oxygen/src/cache/worker-api.ts @@ -15,28 +15,6 @@ export async function createOxygenCache(cacheName: string) { return new OxygenCache(cacheName); } -function notImplementedMessage(methodName: string) { - // Same message as native CF cache: - return `Failed to execute '${methodName}' on 'Cache': the method is not implemented.`; -} - -async function cacheFetch(body: OxygenCachePayload) { - try { - const response = await fetch(OXYGEN_CACHE_URL, { - method: 'POST', - body: JSON.stringify(body), - }); - - if (!response.ok) throw new Error(response.statusText); - - return response; - } catch (unknownError) { - const error = unknownError as Error; - error.message = `[o2:error:cache.${body.method}] ` + error.message; - console.error(error); - } -} - export class OxygenCache implements Cache { #cacheName: string; @@ -149,3 +127,25 @@ export class OxygenCache implements Cache { return cacheResponse ? await cacheResponse.json() : false; } } + +function notImplementedMessage(methodName: string) { + // Same message as native CF cache: + return `Failed to execute '${methodName}' on 'Cache': the method is not implemented.`; +} + +async function cacheFetch(body: OxygenCachePayload) { + try { + const response = await fetch(OXYGEN_CACHE_URL, { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error(response.statusText); + + return response; + } catch (unknownError) { + const error = unknownError as Error; + error.message = `[o2:error:cache.${body.method}] ` + error.message; + console.error(error); + } +} diff --git a/packages/mini-oxygen/src/worker/index.ts b/packages/mini-oxygen/src/worker/index.ts index e6120273c1..3bdc8fa171 100644 --- a/packages/mini-oxygen/src/worker/index.ts +++ b/packages/mini-oxygen/src/worker/index.ts @@ -29,7 +29,7 @@ import type {OnlyBindings, OnlyServices} from './utils.js'; import { isCacheRequest, handleOutboundCacheRequest, - resetBindingStubs, + releaseNodeCacheResources, } from '../cache/node-outbound-handler.js'; export { @@ -185,7 +185,7 @@ export function createMiniOxygen({ {requestHook, assets, unstableOxygenCache}, ), ) - .then(resetBindingStubs), + .then(releaseNodeCacheResources), ); if (unstableOxygenCache) { @@ -197,7 +197,7 @@ export function createMiniOxygen({ assetsServer?.closeAllConnections(); assetsServer?.close(); await mf.dispose(); - resetBindingStubs(); + releaseNodeCacheResources(); isDisposed = true; }, get isDisposed() { From 42dadd42c3cf0fc7b644fc1e83b6845a59723e60 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 19 Jul 2024 13:07:59 +0900 Subject: [PATCH 14/16] Support cache tags --- packages/mini-oxygen/src/cache/common.ts | 2 +- .../src/cache/node-outbound-handler.ts | 59 ++++++++++++++++--- packages/mini-oxygen/src/cache/swr.ts | 2 +- packages/mini-oxygen/src/cache/worker-api.ts | 4 +- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/mini-oxygen/src/cache/common.ts b/packages/mini-oxygen/src/cache/common.ts index cfa9f2591f..f363d08ddc 100644 --- a/packages/mini-oxygen/src/cache/common.ts +++ b/packages/mini-oxygen/src/cache/common.ts @@ -12,6 +12,6 @@ export type OxygenCachePayload = { key: string; } & ( | {method: 'match'} - | {method: 'delete'} + | {method: 'delete'; headers: Array<[string, string]>} | {method: 'put'; value: number[]; headers: Array<[string, string]>} ); diff --git a/packages/mini-oxygen/src/cache/node-outbound-handler.ts b/packages/mini-oxygen/src/cache/node-outbound-handler.ts index d6fb0e1fd8..c6c387aad3 100644 --- a/packages/mini-oxygen/src/cache/node-outbound-handler.ts +++ b/packages/mini-oxygen/src/cache/node-outbound-handler.ts @@ -16,8 +16,10 @@ type FetchResponse = InstanceType; // When Miniflare reloads, we need to reset these in-memory resources. const activeCacheInstances = new Map(); +const cacheTagIndex = new Map>(); export function releaseNodeCacheResources() { activeCacheInstances.clear(); + cacheTagIndex.clear(); } export async function handleOutboundCacheRequest( @@ -33,11 +35,11 @@ export async function handleOutboundCacheRequest( activeCacheInstances.set(body.name, cacheInstance); } - const cacheKey = new Request( - `https://shopify.dev/?cache_name=${encodeURIComponent( - body.name, - )}&cache_key=${encodeURIComponent(body.key)}`, - ) as unknown as RequestInfo; + const cacheKeyUrl = `https://shopify.dev/?cache_name=${encodeURIComponent( + body.name, + )}&cache_key=${encodeURIComponent(body.key)}`; + + const cacheKey = new Request(cacheKeyUrl) as unknown as RequestInfo; try { if (body.method === 'match') { @@ -57,14 +59,28 @@ export async function handleOutboundCacheRequest( cacheResponse, ); } else if (body.method === 'put') { + const headers = addSwrHeaders(body.headers); + const cacheValue = new Response(new Uint8Array(body.value), { - headers: addSwrHeaders(body.headers), + headers: [...headers], }) as unknown as FetchResponse; await cacheInstance.put(cacheKey, cacheValue); + + const cacheTags = headers.get('cache-tags')?.split(','); + if (cacheTags) { + indexKeyByTags(cacheKeyUrl, cacheTags); + } + return new Response(); } else if (body.method === 'delete') { - const isRemoved = await cacheInstance.delete(cacheKey); + const headers = new Headers(body.headers); + const cacheTags = headers.get('cache-tags')?.split(','); + + const isRemoved = cacheTags + ? await deleteTaggedKeys(cacheInstance, cacheTags) + : await cacheInstance.delete(cacheKey); + return Response.json(isRemoved); } else { throw new Error(`cache.${(body as any).method} is not implemented`); @@ -74,3 +90,32 @@ export async function handleOutboundCacheRequest( return new Response((error as Error).message, {status: 500}); } } + +function indexKeyByTags(keyUrl: string, tags: string[]) { + for (const tag of tags) { + const indexedKeys = cacheTagIndex.get(tag) ?? new Set(); + indexedKeys.add(keyUrl); + cacheTagIndex.set(tag, indexedKeys); + } +} + +async function deleteTaggedKeys(cacheInstance: Cache, tags: string[]) { + let hasRemovedSomethingAtAll = false; + + for (const tag of tags) { + const indexedKeys = cacheTagIndex.get(tag); + if (indexedKeys) { + hasRemovedSomethingAtAll = indexedKeys.size > 0; + + for (const keyUrl of indexedKeys) { + await cacheInstance.delete( + new Request(keyUrl) as unknown as RequestInfo, + ); + } + + indexedKeys.clear(); + } + } + + return hasRemovedSomethingAtAll; +} diff --git a/packages/mini-oxygen/src/cache/swr.ts b/packages/mini-oxygen/src/cache/swr.ts index b6d1dd9901..d8db399ddd 100644 --- a/packages/mini-oxygen/src/cache/swr.ts +++ b/packages/mini-oxygen/src/cache/swr.ts @@ -26,7 +26,7 @@ export function addSwrHeaders(originalHeaders: Array<[string, string]>) { headers.set(REAL_CACHE_CONTROL, cacheControlHeader); headers.set(CACHE_PUT_DATE, String(Date.now())); - return [...headers]; + return headers; } export function isStale(response: Response) { diff --git a/packages/mini-oxygen/src/cache/worker-api.ts b/packages/mini-oxygen/src/cache/worker-api.ts index 4e990928ec..0d5c1fb1e1 100644 --- a/packages/mini-oxygen/src/cache/worker-api.ts +++ b/packages/mini-oxygen/src/cache/worker-api.ts @@ -73,12 +73,10 @@ export class OxygenCache implements Cache { headers.delete(REAL_CACHE_CONTROL); headers.delete(CACHE_PUT_DATE); - // TODO support tags await cacheFetch({ name: this.#cacheName, method: 'put', key: request.url, - // tags, // options: getCacheOption(userCacheOptions), value: Object.values(new Uint8Array(await response.arrayBuffer())), headers: [...headers], @@ -121,7 +119,7 @@ export class OxygenCache implements Cache { name: this.#cacheName, method: 'delete', key: request.url, - // tags, + headers: [...request.headers], }); return cacheResponse ? await cacheResponse.json() : false; From 4f23543c17505a14aa801de9755feefd0830ad6c Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 19 Jul 2024 17:59:45 +0900 Subject: [PATCH 15/16] Add Oxygen Cache tests --- packages/mini-oxygen/src/worker/e2e.test.ts | 110 ++++++++++++++++---- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/mini-oxygen/src/worker/e2e.test.ts b/packages/mini-oxygen/src/worker/e2e.test.ts index ce60c3cc14..3d3933ebc0 100644 --- a/packages/mini-oxygen/src/worker/e2e.test.ts +++ b/packages/mini-oxygen/src/worker/e2e.test.ts @@ -1,8 +1,9 @@ import path from 'node:path'; +import {fileURLToPath} from 'node:url'; import {mkdir, writeFile, readFile, rm as remove} from 'node:fs/promises'; import {temporaryDirectoryTask} from 'tempy'; import {it, vi, describe, expect} from 'vitest'; -import {transformWithEsbuild} from 'vite'; +import esbuild from 'esbuild'; import {buildAssetsUrl} from './assets.js'; import {createMiniOxygen, type MiniOxygenOptions} from './index.js'; import {findPort} from '../common/find-port.js'; @@ -186,7 +187,7 @@ describe('MiniOxygen Worker Runtime', () => { expect.objectContaining({ stack: expect.stringMatching( // Doesn't show `doStuff` because it's minified - /Error: test\n\s+at \w .*at Object\.fetch/s, + /^Error: test$/, ), }), ); @@ -198,6 +199,60 @@ describe('MiniOxygen Worker Runtime', () => { }, ); }); + + describe('Oxygen Cache', () => { + it('applies polyfill', async () => { + await withFixtures( + async ({writeHandler}) => { + await writeHandler( + async () => { + const cache = await caches.open('test'); + return new Response(cache.constructor.name); + }, + {useOxygenCache: true}, + ); + }, + async ({fetch}) => { + const response = await fetch('/test-cache'); + // If the text is `Cache`, then it's using the native CF implementation + await expect(response.text()).resolves.toMatchObject('OxygenCache'); + }, + ); + }); + + it('caches by key', async () => { + await withFixtures( + async ({writeHandler}) => { + await writeHandler( + async (req) => { + const cache = await caches.open('test'); + const cacheKey = new Request(req.url); + const match = await cache.match(cacheKey); + + if (match) return match; + + await cache.put( + cacheKey, + Response.json(true, { + headers: {'cache-control': 'public, max-age=10'}, + }), + ); + + return Response.json(false); + }, + {useOxygenCache: true}, + ); + }, + async ({fetch}) => { + let response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(false); + + response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(true); + }, + ); + }); + }); }); // -- Test utilities: @@ -213,7 +268,7 @@ type WithFixturesSetupParams = { env: Record, executionContext: ExecutionContext, ) => Response | Promise, - options?: {sourcemap: boolean}, + options?: {sourcemap?: boolean; useOxygenCache?: boolean}, ) => Promise; }; @@ -250,26 +305,44 @@ function withFixtures( const writeAsset: WriteFixture = (filepath, content) => writeFixture(path.join(relativeDistClient, filepath), content); + const absoluteBundlePath = path.join(tmpDir, relativeWorkerEntry); + let unstableOxygenCache = false; + const writeHandler = async ( handler: Function, - {sourcemap = false} = {}, + {sourcemap = false, useOxygenCache = false} = {}, ) => { let code = `export default { fetch: ${handler.toString()} }`; - if (sourcemap) { - const result = await transformWithEsbuild(code, relativeWorkerEntry, { - minify: true, - sourcemap: true, - }); + if (!sourcemap && !useOxygenCache) { + await writeFixture(relativeWorkerEntry, code); + return; + } - code = result.code; - await writeFixture( - relativeWorkerEntry + '.map', - JSON.stringify(result.map), - ); + if (useOxygenCache) { + unstableOxygenCache = true; + code = + `import '${fileURLToPath( + new URL('../cache/polyfill.ts', import.meta.url), + ).replace('.ts', '.js')}';\n` + code; } - await writeFixture(relativeWorkerEntry, code); + await esbuild.build({ + bundle: true, + format: 'esm', + minify: true, + sourcemap: sourcemap ? 'external' : false, + write: true, + outfile: absoluteBundlePath, + target: 'esnext', + keepNames: true, // Important to keep the class name OxygenCache + stdin: { + contents: code, + sourcefile: relativeWorkerEntry, + loader: 'ts', + resolveDir: '.', + }, + }); }; const optionsFromSetup = await setup({ @@ -278,9 +351,10 @@ function withFixtures( writeHandler, }); - const absoluteBundlePath = path.join(tmpDir, relativeWorkerEntry); - const miniOxygenOptions = { + unstableOxygenCache, + requestHook: null, + sourceMapPath: path.join(tmpDir, relativeWorkerEntry + '.map'), assets: { directory: path.join(tmpDir, relativeDistClient), port: await findPort(1347), @@ -299,8 +373,6 @@ function withFixtures( bindings: {...optionsFromSetup?.bindings}, }, ], - sourceMapPath: path.join(tmpDir, relativeWorkerEntry + '.map'), - requestHook: null, } satisfies MiniOxygenOptions; const miniOxygen = createMiniOxygen(miniOxygenOptions); From 4e4796170962c25e984edf6dd78d1a6ca9c5a25c Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Fri, 19 Jul 2024 19:10:35 +0900 Subject: [PATCH 16/16] Test cache tags and SWR --- packages/mini-oxygen/src/cache/common.ts | 1 + packages/mini-oxygen/src/cache/worker-api.ts | 6 +- packages/mini-oxygen/src/worker/e2e.test.ts | 143 +++++++++++++++++-- packages/mini-oxygen/src/worker/index.ts | 2 +- 4 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/mini-oxygen/src/cache/common.ts b/packages/mini-oxygen/src/cache/common.ts index f363d08ddc..92c2444b85 100644 --- a/packages/mini-oxygen/src/cache/common.ts +++ b/packages/mini-oxygen/src/cache/common.ts @@ -1,5 +1,6 @@ export const OXYGEN_CACHE_URL = 'https://oxygen.myshopify.dev/cache'; +export const OXYGEN_CACHE_STATUS_HEADER = 'oxygen-cache-status'; export const CACHE_CONTROL = 'cache-control'; export const REAL_CACHE_CONTROL = 'real-cache-control'; export const CACHE_PUT_DATE = 'cache-put-date'; diff --git a/packages/mini-oxygen/src/cache/worker-api.ts b/packages/mini-oxygen/src/cache/worker-api.ts index 0d5c1fb1e1..cfa609263d 100644 --- a/packages/mini-oxygen/src/cache/worker-api.ts +++ b/packages/mini-oxygen/src/cache/worker-api.ts @@ -6,6 +6,7 @@ import { CACHE_PUT_DATE, type OxygenCacheMatchResponse, type OxygenCachePayload, + OXYGEN_CACHE_STATUS_HEADER, } from './common.js'; export async function createOxygenCache(cacheName: string) { @@ -104,7 +105,10 @@ export class OxygenCache implements Cache { return new Response(new Uint8Array(value), { status: cacheResponse.status ?? 200, - headers: [...cacheResponse.headers, ['oxygen-cache-status', status]], + headers: [ + ...cacheResponse.headers, + [OXYGEN_CACHE_STATUS_HEADER, status], + ], }); } catch (unknownError) { const error = unknownError as Error; diff --git a/packages/mini-oxygen/src/worker/e2e.test.ts b/packages/mini-oxygen/src/worker/e2e.test.ts index 3d3933ebc0..0a6a98dc38 100644 --- a/packages/mini-oxygen/src/worker/e2e.test.ts +++ b/packages/mini-oxygen/src/worker/e2e.test.ts @@ -1,12 +1,20 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {mkdir, writeFile, readFile, rm as remove} from 'node:fs/promises'; +import { + mkdir, + writeFile, + readFile, + rm as remove, + readdir, +} from 'node:fs/promises'; +import {readdirSync} from 'node:fs'; import {temporaryDirectoryTask} from 'tempy'; import {it, vi, describe, expect} from 'vitest'; import esbuild from 'esbuild'; import {buildAssetsUrl} from './assets.js'; import {createMiniOxygen, type MiniOxygenOptions} from './index.js'; import {findPort} from '../common/find-port.js'; +import {OXYGEN_CACHE_STATUS_HEADER} from '../cache/common.js'; describe('MiniOxygen Worker Runtime', () => { it('receives HTML from test worker', async () => { @@ -175,21 +183,24 @@ describe('MiniOxygen Worker Runtime', () => { // -- Test without sourcemaps: await remove(miniOxygenOptions.sourceMapPath!, {force: true}); + await new Promise((resolve) => setTimeout(resolve, 100)); await reloadMiniOxygen(); + await new Promise((resolve) => setTimeout(resolve, 100)); await fetch('/'); await vi.waitFor( () => expect(spy.mock.calls.length).toBeGreaterThan(1), // At least 2 calls ); - // console.error with stack: - expect(spy, 'Logged without sourcemaps').toHaveBeenCalledWith( - expect.objectContaining({ - stack: expect.stringMatching( - // Doesn't show `doStuff` because it's minified - /^Error: test$/, - ), - }), + await vi.waitFor(() => + expect(spy, 'Logged without sourcemaps').toHaveBeenCalledWith( + expect.objectContaining({ + stack: expect.stringMatching( + // Doesn't show `doStuff` because it's minified + /^Error:\s+test$/i, + ), + }), + ), ); // Thrown error is also logged @@ -252,6 +263,115 @@ describe('MiniOxygen Worker Runtime', () => { }, ); }); + + it('returns SWR headers', async () => { + await withFixtures( + async ({writeHandler}) => { + await writeHandler( + async (req) => { + const cache = await caches.open('test'); + const cacheKey = new Request(req.url); + const match = await cache.match(cacheKey); + + if (match) return match; + + await cache.put( + cacheKey, + Response.json(true, { + headers: { + 'cache-control': + 'public, max-age=1, stale-while-revalidate=10', + }, + }), + ); + + return Response.json(false); + }, + {useOxygenCache: true}, + ); + }, + async ({fetch}) => { + let response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(false); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toBeFalsy(); + + response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(true); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toEqual( + 'HIT', + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(true); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toEqual( + 'STALE', + ); + }, + ); + }); + + it('deletes by tag', async () => { + await withFixtures( + async ({writeHandler}) => { + await writeHandler( + async (req) => { + const cache = await caches.open('test'); + + if (req.method === 'DELETE') { + return Response.json( + await cache.delete( + new Request(req.url + 'non-matching-key', { + headers: [...req.headers], + }), + ), + ); + } + + const cacheKey = new Request(req.url); + const match = await cache.match(cacheKey); + + if (match) return match; + + await cache.put( + cacheKey, + Response.json(true, { + headers: { + 'cache-control': 'public, max-age=10', + 'cache-tags': 'tag1,tag2', + }, + }), + ); + + return Response.json(false); + }, + {useOxygenCache: true}, + ); + }, + async ({fetch}) => { + let response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(false); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toBeFalsy(); + + response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(true); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toEqual( + 'HIT', + ); + + response = await fetch('/test-cache', { + method: 'DELETE', + headers: {'cache-tags': 'tag2'}, + }); + await expect(response.json()).resolves.toEqual(true); + + response = await fetch('/test-cache'); + await expect(response.json()).resolves.toEqual(false); + expect(response.headers.get(OXYGEN_CACHE_STATUS_HEADER)).toBeFalsy(); // MISS + }, + ); + }); }); }); @@ -273,7 +393,7 @@ type WithFixturesSetupParams = { }; type WithFixturesTestParams = WithFixturesSetupParams & { - fetch: (pathname: string) => Promise; + fetch: (pathname: string, init?: RequestInit) => Promise; fetchAsset: (pathname: string) => Promise; reloadMiniOxygen: (options?: ReloadOptions) => Promise; miniOxygenOptions: Partial; @@ -402,7 +522,8 @@ function withFixtures( writeAsset, reloadMiniOxygen, miniOxygenOptions, - fetch: (pathname: string) => fetch(workerUrl.origin + pathname), + fetch: (pathname: string, init) => + fetch(workerUrl.origin + pathname, init), fetchAsset: (pathname: string) => fetch(buildAssetsUrl(miniOxygenOptions.assets.port) + pathname), }); diff --git a/packages/mini-oxygen/src/worker/index.ts b/packages/mini-oxygen/src/worker/index.ts index 3bdc8fa171..7c872d0680 100644 --- a/packages/mini-oxygen/src/worker/index.ts +++ b/packages/mini-oxygen/src/worker/index.ts @@ -271,7 +271,7 @@ function buildMiniflareOptions( // host: 'localhost', inspectorPort: 0, liveReload: false, - ...(isO2Verbose() + ...(mfOverwriteOptions.verbose || isO2Verbose() ? {verbose: true} : { verbose: false,