77 */
88
99import { createHash } from 'node:crypto'
10- import { promises as fs } from 'node:fs'
11- import * as path from 'node:path'
1210import { strict as assert } from 'node:assert'
1311import {
1412 IContextObject ,
@@ -17,8 +15,11 @@ import {
1715 INodeType ,
1816 INodeTypeDescription ,
1917 NodeConnectionType ,
18+ NodeOperationError ,
2019} from 'n8n-workflow'
2120
21+ import { CacheBackend , S3Backend , joinPrefix } from './storage'
22+
2223const getItemIndex = ( pairedItem : INodeExecutionData [ 'pairedItem' ] ) : number => {
2324 if ( Array . isArray ( pairedItem ) ) {
2425 return pairedItem [ 0 ] ?. item ?? 0
@@ -54,7 +55,7 @@ const processItemData = (item: INodeExecutionData, cacheKeyFields: string) =>
5455const generateCacheMetadata = (
5556 items : INodeExecutionData | INodeExecutionData [ ] ,
5657 cacheKeyFields : string ,
57- cacheDir : string ,
58+ prefix : string ,
5859 nodeId : string ,
5960) => {
6061 const dataToHash = Array . isArray ( items )
@@ -88,51 +89,58 @@ const generateCacheMetadata = (
8889 const hash = createHash ( 'sha256' )
8990 . update ( JSON . stringify ( { nodeId, data : sortedData } ) )
9091 . digest ( 'hex' )
91- const cachePath = path . join ( cacheDir , `${ hash } .cache` )
92+ const objectKey = joinPrefix ( prefix , `${ hash } .cache` )
9293
9394 return {
9495 cacheKey : hash ,
95- cachePath,
96+ cachePath : objectKey ,
9697 }
9798}
9899
99- const writeToCacheFile = async (
100+ const writeToCache = async (
100101 items : INodeExecutionData | INodeExecutionData [ ] ,
101102 context : IContextObject ,
102- batchMode = false ,
103+ backend ?: CacheBackend ,
103104) => {
104105 // If array, any item serves as they all share the same $smartcache object
105106 const firstItem = Array . isArray ( items ) ? items [ 0 ] : items
106107 assert ( firstItem , 'Items cannot be empty' )
107108 const cachePath = getCachePathFromItem ( firstItem , context )
108- await fs . mkdir ( path . dirname ( cachePath ) , { recursive : true } )
109- await fs . writeFile ( cachePath , JSON . stringify ( items ) )
110- console . log ( ` Wrote ${ batchMode ? 'batch' : 'item' } to cache file: ${ cachePath } `)
109+ assert ( backend , 'Cache backend not available' )
110+ await backend . put ( cachePath , items )
111+ console . debug ( `[SmartCache] Wrote to cache at ${ cachePath } `)
111112}
112113
113- const handleCacheHit = async ( cachePath : string , ttl : number ) => {
114- const stats = await fs . stat ( cachePath )
115- const cacheAge = ( Date . now ( ) - stats . mtime . getTime ( ) ) / ( 1000 * 60 * 60 )
116-
117- if ( ttl > 0 && cacheAge >= ttl ) {
118- return { status : 'expired' , cacheAge }
114+ const handleCacheHit = async (
115+ cachePath : string ,
116+ ttl : number ,
117+ backend : CacheBackend ,
118+ ) => {
119+ const head = await backend . head ( cachePath )
120+ if ( ! head ) return { status : 'miss' as const }
121+ if ( ttl > 0 ) {
122+ const cacheAge = ( Date . now ( ) - head . lastModified . getTime ( ) ) / ( 1000 * 60 * 60 )
123+ if ( cacheAge >= ttl ) {
124+ return { status : 'expired' as const , cacheAge }
125+ }
119126 }
120-
121- const cachedContent = JSON . parse ( await fs . readFile ( cachePath , 'utf-8' ) )
122- return { status : 'hit' , content : cachedContent }
127+ const content = await backend . get ( cachePath )
128+ if ( content == null ) return { status : 'miss' as const }
129+ return { status : 'hit' as const , content }
123130}
124131
125132const processBatch = async (
126133 items : INodeExecutionData [ ] ,
127134 context : IContextObject ,
128135 cacheKeyFields : string ,
129- cacheDir : string ,
136+ prefix : string ,
130137 force : boolean ,
131138 ttl : number ,
132139 logger : IExecuteFunctions [ 'logger' ] ,
133140 nodeId : string ,
141+ backend : CacheBackend ,
134142) => {
135- const $smartCache = generateCacheMetadata ( items , cacheKeyFields , cacheDir , nodeId )
143+ const $smartCache = generateCacheMetadata ( items , cacheKeyFields , prefix , nodeId )
136144
137145 // Store cache metadata for each item
138146 items . forEach ( ( item ) => {
@@ -152,7 +160,7 @@ const processBatch = async (
152160 }
153161
154162 try {
155- const result = await handleCacheHit ( $smartCache . cachePath , ttl )
163+ const result = await handleCacheHit ( $smartCache . cachePath , ttl , backend )
156164 if ( result . status === 'hit' ) {
157165 return { hits : Array . isArray ( result . content ) ? result . content : [ result . content ] , misses : [ ] }
158166 }
@@ -170,13 +178,14 @@ const processSingleItem = async (
170178 item : INodeExecutionData ,
171179 context : IContextObject ,
172180 cacheKeyFields : string ,
173- cacheDir : string ,
181+ prefix : string ,
174182 force : boolean ,
175183 ttl : number ,
176184 logger : IExecuteFunctions [ 'logger' ] ,
177185 nodeId : string ,
186+ backend : CacheBackend ,
178187) => {
179- const $smartCache = generateCacheMetadata ( item , cacheKeyFields , cacheDir , nodeId )
188+ const $smartCache = generateCacheMetadata ( item , cacheKeyFields , prefix , nodeId )
180189 const itemIndex = getItemIndex ( item . pairedItem )
181190 context [ itemIndex ] = $smartCache
182191
@@ -192,7 +201,7 @@ const processSingleItem = async (
192201 }
193202
194203 try {
195- const result = await handleCacheHit ( $smartCache . cachePath , ttl )
204+ const result = await handleCacheHit ( $smartCache . cachePath , ttl , backend )
196205 if ( result . status === 'hit' ) {
197206 return { hit : result . content , miss : null }
198207 }
@@ -213,7 +222,8 @@ export class SmartCache implements INodeType {
213222 icon : 'file:smartCache.svg' ,
214223 group : [ 'transform' ] ,
215224 version : 1 ,
216- description : 'Intelligent caching node with automatic hash generation and TTL support' ,
225+ description :
226+ 'Intelligent caching node with automatic hash generation and TTL support. Persists cache objects to S3 (or S3-compatible) storage.' ,
217227 subtitle :
218228 '={{ ($parameter["batchMode"] ? "Batch" : "Individual") + ($parameter["force"] ? " • ⚠️ Force Miss" : "") }}' ,
219229 documentationUrl : 'https://github.com/skadaai/n8n-nodes-smartcache#readme' ,
@@ -243,7 +253,31 @@ export class SmartCache implements INodeType {
243253 required : true ,
244254 } ,
245255 ] ,
256+ /* eslint-disable n8n-nodes-base/node-class-description-credentials-name-unsuffixed */
257+ credentials : [
258+ {
259+ name : 's3' ,
260+ required : true ,
261+ } ,
262+ ] ,
263+ /* eslint-enable n8n-nodes-base/node-class-description-credentials-name-unsuffixed */
246264 properties : [
265+ {
266+ displayName : 'S3 Bucket' ,
267+ name : 'bucket' ,
268+ type : 'string' ,
269+ default : '' ,
270+ description : 'Bucket where cache objects are stored' ,
271+ noDataExpression : true ,
272+ } ,
273+ {
274+ displayName : 'Path Prefix' ,
275+ name : 'cacheDir' ,
276+ type : 'string' ,
277+ default : 'smartcache' ,
278+ description : 'Prefix for object keys inside the S3 bucket (e.g., $smartcache)' ,
279+ noDataExpression : true ,
280+ } ,
247281 {
248282 displayName : 'Batch Mode' ,
249283 name : 'batchMode' ,
@@ -276,15 +310,6 @@ export class SmartCache implements INodeType {
276310 default : 24 ,
277311 description : 'Time-to-live for cache entries in hours. Use 0 for infinite.' ,
278312 } ,
279- {
280- displayName : 'Cache Directory' ,
281- name : 'cacheDir' ,
282- type : 'string' ,
283- default : '/tmp/n8n-smartcache' ,
284- description :
285- 'Directory where cache files will be stored. Must be writable by the n8n process.' ,
286- noDataExpression : true ,
287- } ,
288313 ] ,
289314 }
290315
@@ -295,15 +320,61 @@ export class SmartCache implements INodeType {
295320 const cacheKeyFields = ( this . getNodeParameter ( 'cacheKeyFields' , 0 ) as string ) . trim ( )
296321 const batchMode = this . getNodeParameter ( 'batchMode' , 0 ) as boolean
297322 const context = this . getContext ( 'node' )
323+ if ( force ) {
324+ this . logger . warn ( '[SmartCache] Force Miss is enabled: cache reads will be bypassed and new objects will be written' )
325+ }
326+ // Create S3 backend (credentials are required by node definition)
327+ const creds = ( await this . getCredentials ( 's3' ) ) as unknown as {
328+ accessKeyId : string
329+ secretAccessKey : string
330+ region : string
331+ endpoint ?: string
332+ forcePathStyle ?: boolean
333+ ignoreSSL ?: boolean
334+ }
335+ if ( ! creds ) {
336+ throw new NodeOperationError ( this . getNode ( ) , 'S3 credentials are required' )
337+ }
338+ const bucket = ( this . getNodeParameter ( 'bucket' , 0 ) as string ) . trim ( )
339+ if ( ! bucket ) {
340+ throw new NodeOperationError ( this . getNode ( ) , 'S3 bucket is required' )
341+ }
342+ const backend : CacheBackend = new S3Backend (
343+ {
344+ accessKeyId : String ( creds . accessKeyId ) ,
345+ secretAccessKey : String ( creds . secretAccessKey ) ,
346+ sessionToken : ( creds as { sessionToken ?: string } ) . sessionToken ,
347+ region : String ( creds . region ) ,
348+ bucket,
349+ endpoint : creds . endpoint ? String ( creds . endpoint ) : undefined ,
350+ forcePathStyle : creds . forcePathStyle ,
351+ ignoreSSL : creds . ignoreSSL ,
352+ } ,
353+ this . logger ,
354+ this ,
355+ )
356+
357+ // Validate bucket exists early with a lightweight HEAD
358+ try {
359+ if ( backend instanceof S3Backend ) {
360+ await backend . ensureBucketAccessible ( )
361+ }
362+ } catch ( err ) {
363+ throw new NodeOperationError (
364+ this . getNode ( ) ,
365+ err instanceof Error ? err . message : String ( err ) ,
366+ )
367+ }
298368
299369 const mainInput = this . getInputData ( 0 ) // Input 1
300370 const cacheInput = this . getInputData ( 1 ) // Input 2 (write)
301371
302372 this . logger . debug ( '[SmartCache] SmartCache initialized with parameters:' , {
303373 force,
304374 ttl,
305- cacheDir,
375+ cachePrefix : cacheDir ,
306376 cacheKeyFields,
377+ backend : backend . constructor . name ,
307378 } )
308379
309380 // Early return if both inputs are empty
@@ -325,6 +396,7 @@ export class SmartCache implements INodeType {
325396 ttl ,
326397 this . logger ,
327398 nodeId ,
399+ backend ,
328400 )
329401 : await Promise . all (
330402 mainInput . map ( ( item ) =>
@@ -337,6 +409,7 @@ export class SmartCache implements INodeType {
337409 ttl ,
338410 this . logger ,
339411 nodeId ,
412+ backend ,
340413 ) ,
341414 ) ,
342415 ) . then ( ( results ) => ( {
@@ -362,7 +435,14 @@ export class SmartCache implements INodeType {
362435 getItemIndex ( firstItem . pairedItem ) !== undefined ,
363436 'Write input items must come from cache miss output' ,
364437 )
365- await writeToCacheFile ( cacheInput , context , true )
438+ try {
439+ await writeToCache ( cacheInput , context , backend )
440+ } catch ( err ) {
441+ throw new NodeOperationError (
442+ this . getNode ( ) ,
443+ `Failed to persist cache to S3: ${ String ( err instanceof Error ? err . message : err ) } ` ,
444+ )
445+ }
366446 return [ cacheInput , [ ] ]
367447 }
368448
@@ -372,7 +452,14 @@ export class SmartCache implements INodeType {
372452 getItemIndex ( item . pairedItem ) !== undefined ,
373453 'Write input items must come from cache miss output' ,
374454 )
375- await writeToCacheFile ( item , context )
455+ try {
456+ await writeToCache ( item , context , backend )
457+ } catch ( err ) {
458+ throw new NodeOperationError (
459+ this . getNode ( ) ,
460+ `Failed to persist cache to S3: ${ String ( err instanceof Error ? err . message : err ) } ` ,
461+ )
462+ }
376463 results . push ( item )
377464 }
378465
0 commit comments