Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

resolves #1507 introduce an in-memory cache (Node.js) #1523

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +28 to +33
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mojavelinux I'm a bit concerned about this hack, could we find a better "hook" in Asciidoctor core?

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 clear = () => {
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 clear
`clear()`
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.clear()
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.clear()
asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX)
}
})
})
})

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