Skip to content

Commit

Permalink
resolves asciidoctor#1507 introduce an in-memory cache (Node.js)
Browse files Browse the repository at this point in the history
  • Loading branch information
ggrossetie committed Apr 24, 2022
1 parent 8b555d3 commit 2a3878a
Show file tree
Hide file tree
Showing 13 changed files with 476 additions and 51 deletions.
1 change: 1 addition & 0 deletions packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
require 'asciidoctor/js/asciidoctor_ext/node/open_uri'
require 'asciidoctor/js/asciidoctor_ext/node/stylesheet'
require 'asciidoctor/js/asciidoctor_ext/node/template'
require 'asciidoctor/js/asciidoctor_ext/node/helpers'
35 changes: 35 additions & 0 deletions packages/core/lib/asciidoctor/js/asciidoctor_ext/node/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module Asciidoctor
# Internal: Except where noted, a module that contains internal helper functions.
module Helpers
module_function

# preserve the original require_library method
alias original_require_library require_library

# Public: Require the specified library using Kernel#require.
#
# Attempts to load the library specified in the first argument using the
# Kernel#require. Rescues the LoadError if the library is not available and
# passes a message to Kernel#raise if on_failure is :abort or Kernel#warn if
# on_failure is :warn to communicate to the user that processing is being
# aborted or functionality is disabled, respectively. If a gem_name is
# specified, the message communicates that a required gem is not available.
#
# name - the String name of the library to require.
# gem_name - a Boolean that indicates whether this library is provided by a RubyGem,
# or the String name of the RubyGem if it differs from the library name
# (default: true)
# on_failure - a Symbol that indicates how to handle a load failure (:abort, :warn, :ignore) (default: :abort)
#
# Returns The [Boolean] return value of Kernel#require if the library can be loaded.
# Otherwise, if on_failure is :abort, Kernel#raise is called with an appropriate message.
# Otherwise, if on_failure is :warn, Kernel#warn is called with an appropriate message and nil returned.
# Otherwise, nil is returned.
def require_library name, gem_name = true, on_failure = :abort
if name == 'open-uri/cached'
`return Opal.Asciidoctor.Cache.enable()`
end
original_require_library name, gem_name, on_failure
end
end
end
4 changes: 3 additions & 1 deletion packages/core/lib/asciidoctor/js/opal_ext/uri.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module URI
def self.parse str
str.extend URI
# REMIND: Cannot create property '$$meta' on string in strict mode!
#str.extend URI
str
end

def path
Expand Down
156 changes: 156 additions & 0 deletions packages/core/lib/open-uri/cached.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# copied from https://github.com/tigris/open-uri-cached/blob/master/lib/open-uri/cached.rb
module OpenURI
class << self
# preserve the original open_uri method
alias original_open_uri open_uri
def cache_open_uri(uri, *rest, &block)
response = Cache.get(uri.to_s) ||
Cache.set(uri.to_s, original_open_uri(uri, *rest))

if block_given?
begin
yield response
ensure
response.close
end
else
response
end
end
# replace the existing open_uri method
alias open_uri cache_open_uri
end

class Cache
class << self

%x{
// largely inspired by https://github.com/isaacs/node-lru-cache/blob/master/index.js
let cache = new Map()
let max = 16000000 // bytes
let length = 0
let lruList = []
class Entry {
constructor (key, value, length) {
this.key = key
this.value = value
this.length = length
}
}
const trim = () => {
while (length > max) {
pop()
}
}
const reset = () => {
cache = new Map()
length = 0
lruList = []
}
const pop = () => {
const leastRecentEntry = lruList.pop()
if (leastRecentEntry) {
length -= leastRecentEntry.length
cache.delete(leastRecentEntry.key)
}
}
const del = (entry) => {
if (entry) {
length -= entry.length
cache.delete(entry.key)
const entryIndex = lruList.indexOf(entry)
if (entryIndex > -1) {
lruList.splice(entryIndex, 1)
}
}
}
}

##
# Retrieve file content and meta data from cache
# @param [String] key
# @return [StringIO]
def get(key)
%x{
const cacheKey = crypto.createHash('sha256').update(key).digest('hex')
if (cache.has(cacheKey)) {
const entry = cache.get(cacheKey)
const io = Opal.$$$('::', 'StringIO').$new()
io['$<<'](entry.value)
io.$rewind()
return io
}
}

nil
end

# Cache file content
# @param [String] key
# URL of content to be cached
# @param [StringIO] value
# value to be cached, typically StringIO returned from `original_open_uri`
# @return [StringIO]
# Returns value
def set(key, value)
%x{
const cacheKey = crypto.createHash('sha256').update(key).digest('hex')
const contents = value.string
const len = contents.length
if (cache.has(cacheKey)) {
if (len > max) {
// oversized object, dispose the current entry.
del(cache.get(cacheKey))
return value
}
// update current entry
const entry = cache.get(cacheKey)
// remove existing entry in the LRU list (unless the entry is already the head).
const listIndex = lruList.indexOf(entry)
if (listIndex > 0) {
lruList.splice(listIndex, 1)
lruList.unshift(entry)
}
entry.value = value
length += len - entry.length
entry.length = len
trim()
return value
}
const entry = new Entry(cacheKey, value, len)
// oversized objects fall out of cache automatically.
if (entry.length > max) {
return value
}
length += entry.length
lruList.unshift(entry)
cache.set(cacheKey, entry)
trim()
return value
}
end

def max=(maxLength)
%x{
if (typeof maxLength !== 'number' || maxLength < 0) {
throw new TypeError('max must be a non-negative number')
}
max = maxLength || Infinity
trim()
}
end

def reset
`reset()`
end
end
end
end
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"scripts": {
"test:graalvm": "node tasks/graalvm.cjs",
"test:node": "mocha spec/*/*.spec.cjs && npm run test:node:esm",
"test:node:esm": "mocha --experimental-json-modules spec/node/asciidoctor.spec.js",
"test:node:esm": "mocha spec/node/asciidoctor.spec.js",
"test:browser": "node spec/browser/run.cjs",
"test:types": "rm -f types/tests.js && eslint types --ext .ts && tsc --build types/tsconfig.json && node --input-type=commonjs types/tests.js",
"test": "node tasks/test/unsupported-features.cjs && npm run test:node && npm run test:browser && npm run test:types",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/spec/fixtures/images/cc-heart.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions packages/core/spec/node/asciidoctor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import chartBlockMacro from '../share/extensions/chart-block.cjs'
import smileyInlineMacro from '../share/extensions/smiley-inline-macro.cjs'
import loremBlockMacro from '../share/extensions/lorem-block-macro.cjs'

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const packageJson = require('../../package.json')

const expect = chai.expect
chai.use(dirtyChai)

Expand Down Expand Up @@ -2207,6 +2211,109 @@ content
content`, options)
expect(html).to.contain('0. Chapter A')
})

describe('Cache', () => {
it('should cache remote SVG when allow-uri-read, cache-uri, and inline option are set', async () => {
try {
const input = `
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
`
await mockServer.resetRequests()
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '', 'cache-uri': '' } })
const requestsReceived = await mockServer.getRequests()
expect(requestsReceived.length).to.equal(1)
} finally {
asciidoctor.Cache.disable()
}
})

it('should not cache remote SVG when cache-uri is absent (undefined)', async () => {
const input = `
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
`
await mockServer.resetRequests()
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '' } })
const requestsReceived = await mockServer.getRequests()
expect(requestsReceived.length).to.equal(3)
})

it('should cache remote include when cache-uri is set', async () => {
try {
const input = `
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2]
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4]
`
await mockServer.resetRequests()
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
const requestsReceived = await mockServer.getRequests()
expect(requestsReceived.length).to.equal(1)
} finally {
asciidoctor.Cache.disable()
}
})

it('should not cache file if the size exceed the max cache', async () => {
try {
asciidoctor.Cache.setMax(1)
const input = `
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2]
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4]
`
await mockServer.resetRequests()
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
const requestsReceived = await mockServer.getRequests()
expect(requestsReceived.length).to.equal(2)
} finally {
asciidoctor.Cache.disable()
asciidoctor.Cache.reset()
asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX)
}
})

it('should not exceed max cache size', async () => {
try {
// cc-zero.svg exact size/length
const contentLength = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'images', 'cc-zero.svg'), 'utf-8').length
asciidoctor.Cache.setMax(contentLength)
const input = `
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
// will remove cc-zero.svg from the cache!
image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline]
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
`
await mockServer.resetRequests()
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
const requestsReceived = await mockServer.getRequests()
expect(requestsReceived.length).to.equal(3)
expect(requestsReceived.map((request) => request.pathname)).to.have.members(['/cc-zero.svg', '/cc-heart.svg', '/cc-heart.svg'])
} finally {
asciidoctor.Cache.disable()
asciidoctor.Cache.reset()
asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX)
}
})
})
})

describe('Docinfo files', () => {
Expand Down
Loading

0 comments on commit 2a3878a

Please sign in to comment.