Skip to content
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
144 changes: 133 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ function morgan (format, options) {
// record request start
recordStartTime.call(req)

// Write a resolved log value to the stream, preserving object-mode
// semantics: if the stream is in object mode and the value is an object,
// write it directly; otherwise coerce to string and append a newline.
function writeLog (val) {
if (stream.writableObjectMode && typeof val === 'object') {
stream.write(val)
} else {
stream.write(val + '\n')
}
}

function logRequest () {
if (skip !== false && skip(req, res)) {
debug('skip request')
Expand All @@ -154,12 +165,26 @@ function morgan (format, options) {
return
}

debug('log request')
if (stream.writableObjectMode && typeof line === 'object') {
stream.write(line)
} else {
stream.write(line + '\n')
// Support Promise-returning format functions and compiled formats with
// async tokens: if formatLine returned a thenable, defer the write.
if (hasThenProperty(line)) {
Promise.resolve(line).then(function (str) {
if (str == null) {
debug('skip line')
return
}
debug('log request')
writeLog(str)
}).catch(function (err) {
// A rejected async token must not become an unhandled rejection
// and crash the host process. Log via debug and move on.
debug('async token error: %s', err && err.message ? err.message : err)
})
return
}

debug('log request')
writeLog(line)
};

if (immediate) {
Expand Down Expand Up @@ -441,17 +466,97 @@ function compile (format) {
throw new TypeError('argument format must be a string')
}

var fmt = String(JSON.stringify(format))
var js = ' "use strict"\n return ' + fmt.replace(/:([-\w]{2,})(?:\[([^\]]+)\])?/g, function (_, name, arg) {
// Build two parallel arrays as we scan the format string:
// decls – "var _v0 = tokens[\"name\"](req, res, \"arg\")" declarations
// pieces – the string-assembly expression that references _v0, _v1, …
// interleaved with the literal text segments
var decls = []
var pieces = []
var idx = 0
var lastIndex = 0
var tokenRe = /:([-\w]{2,})(?:\[([^\]]+)\])?/g
var match

while ((match = tokenRe.exec(format)) !== null) {
// push the literal segment before this token
pieces.push(JSON.stringify(format.slice(lastIndex, match.index)))
lastIndex = match.index + match[0].length

var name = match[1]
var arg = match[2]
var tokenArguments = 'req, res'
var tokenFunction = 'tokens[' + String(JSON.stringify(name)) + ']'
var tokenFunction = 'tokens[' + JSON.stringify(name) + ']'
var varName = '_v' + idx

if (arg !== undefined) {
tokenArguments += ', ' + String(JSON.stringify(arg))
tokenArguments += ', ' + JSON.stringify(arg)
}

return '" +\n (' + tokenFunction + '(' + tokenArguments + ') || "-") + "'
})
decls.push(' var ' + varName + ' = ' + tokenFunction + '(' + tokenArguments + ')')
// Each token piece is (_vN || "-") to match existing fallback behaviour
pieces.push('(' + varName + ' || "-")')
idx++
}

// push any trailing literal
pieces.push(JSON.stringify(format.slice(lastIndex)))

var varCount = idx

// Build the synchronous return expression (identical semantics to the old
// compile output so existing tests are unaffected)
var syncExpr = pieces.join(' + ')

var js = ' "use strict"\n'

if (varCount === 0) {
// No tokens at all – plain string, same as before
js += ' return ' + syncExpr
} else {
// Helper to safely detect thenables even if accessing .then throws
js += ' function hasThenProperty (val) {\n'
js += ' try {\n'
js += ' return val != null && typeof val.then === \'function\'\n'
js += ' } catch (err) {\n'
js += ' return true\n'
js += ' }\n'
js += ' }\n'

// Declare all token variables up front
js += decls.join('\n') + '\n'

// Build the Promise-check expression: any _vN that is non-null and thenable
var checks = []
for (var i = 0; i < varCount; i++) {
checks.push('hasThenProperty(_v' + i + ')')
}

// Build the resolved-values expression for the async path:
// inside Promise.all callback the values come in as _r[0], _r[1], …
var asyncPieces = pieces.map(function (p, pi) {
// odd-indexed pieces are token expressions "(_vN || "-")" → "(_r[K] || "-")"
// even-indexed pieces are literal strings
if (pi % 2 === 1) {
var k = (pi - 1) / 2
return '(_r[' + k + '] || "-")'
}
return p
})
var asyncExpr = asyncPieces.join(' + ')

// Build the Promise.all values array: _v0, _v1, …
var promiseArgs = []
for (var j = 0; j < varCount; j++) {
promiseArgs.push('_v' + j)
}

js += ' if (' + checks.join(' || ') + ') {\n'
js += ' return Promise.all([' + promiseArgs.join(', ') + ']).then(function (_r) {\n'
js += ' return ' + asyncExpr + '\n'
js += ' })\n'
js += ' }\n'
js += ' return ' + syncExpr
}

// eslint-disable-next-line no-new-func
return new Function('tokens, req, res', js)
Expand Down Expand Up @@ -550,6 +655,23 @@ function headersSent (res) {
: res.headersSent
}

/**
* Determine if a value has a then-property (thenable),
* guarding against throwing property getters.
*
* @param {object} val
* @return {boolean}
* @private
*/

function hasThenProperty (val) {
try {
return val != null && typeof val.then === 'function'
} catch (err) {
return true
}
}

/**
* Pad number to two digits.
*
Expand Down
187 changes: 187 additions & 0 deletions test/morgan.js
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,193 @@ describe('morgan()', function () {
test.write('0')
})
})

describe('async tokens', function () {
it('should resolve Promise returned by token in string format', function (done) {
var cb = after(2, function (err, res, line) {
if (err) return done(err)
assert.strictEqual(line, 'GET /foo 200')
done()
})

var stream = createLineStream(function (line) {
cb(null, null, line)
})

morgan.token('async-method', function (req) {
return Promise.resolve(req.method)
})

request(createServer(':async-method :url :status', { stream: stream }))
.get('/foo')
.expect(200, cb)
})

it('should not log [object Promise] for async token', function (done) {
var cb = after(2, function (err, res, line) {
if (err) return done(err)
assert.strictEqual(line.indexOf('[object Promise]'), -1)
assert.strictEqual(line, 'ahmed')
done()
})

var stream = createLineStream(function (line) {
cb(null, null, line)
})

morgan.token('async-user', function () {
return Promise.resolve('ahmed')
})

request(createServer(':async-user', { stream: stream }))
.get('/')
.expect(200, cb)
})

it('should keep synchronous tokens working alongside async tokens', function (done) {
var cb = after(2, function (err, res, line) {
if (err) return done(err)
assert.ok(/^GET \/sync-test \d+$/.test(line))
done()
})

var stream = createLineStream(function (line) {
cb(null, null, line)
})

morgan.token('async-status', function (req, res) {
return Promise.resolve(String(res.statusCode))
})

request(createServer(':method :url :async-status', { stream: stream }))
.get('/sync-test')
.expect(200, cb)
})

it('should not crash when a token returns a rejected Promise', function (done) {
// Regression test for PR #380 P1: an unhandled rejection from an async
// token must not terminate the Node.js process. The request must
// complete normally even though the log line is silently dropped.
morgan.token('reject-token', function () {
return Promise.reject(new Error('token failure'))
})

request(createServer(':method :url :reject-token', {}))
.get('/')
.expect(200, done)
})

it('should pass resolved object to object-mode stream unchanged', function (done) {
// Regression test for PR #380 P2: the async path must honour
// writableObjectMode exactly like the synchronous path does.
var received = null
var cb = after(2, function (err) {
if (err) return done(err)
assert.deepEqual(received, { method: 'GET', url: '/' })
done()
})

var stream = {
writableObjectMode: true,
write: function (obj) {
received = obj
cb(null, null)
}
}

function format (tokens, req) {
return Promise.resolve({ method: req.method, url: req.url })
}

request(createServer(format, { stream: stream }))
.get('/')
.expect(200, cb)
})

it('should handle thenable whose then() returns undefined', function (done) {
var cb = after(2, function (err, res, line) {
if (err) return done(err)
assert.strictEqual(line, 'ok')
done()
})

var stream = createLineStream(function (line) {
cb(null, null, line)
})

function format () {
return {
then: function (resolve) {
resolve('ok')
}
}
}

request(createServer(format, { stream: stream }))
.get('/')
.expect(200, cb)
})

it('should handle format function returning value with throwing then getter', function (done) {
var bad = {}
Object.defineProperty(bad, 'then', {
get: function () {
throw new Error('then getter failed')
}
})

function format () {
return bad
}

var uncaught = []
function onUncaught (err) {
uncaught.push(err)
}
process.on('uncaughtException', onUncaught)
process.on('unhandledRejection', onUncaught)

request(createServer(format, {}))
.get('/')
.expect(200, function (err) {
process.removeListener('uncaughtException', onUncaught)
process.removeListener('unhandledRejection', onUncaught)
if (err) return done(err)
assert.strictEqual(uncaught.length, 0)
done()
})
})

it('should handle token returning value with throwing then getter in compiled format', function (done) {
var bad = {}
Object.defineProperty(bad, 'then', {
get: function () {
throw new Error('token then getter failed')
}
})

morgan.token('throwing-then-token', function () {
return bad
})

var uncaught = []
function onUncaught (err) {
uncaught.push(err)
}
process.on('uncaughtException', onUncaught)
process.on('unhandledRejection', onUncaught)

request(createServer(':method :url :throwing-then-token', {}))
.get('/')
.expect(200, function (err) {
process.removeListener('uncaughtException', onUncaught)
process.removeListener('unhandledRejection', onUncaught)
if (err) return done(err)
assert.strictEqual(uncaught.length, 0)
done()
})
})
})
})

describe('formats', function () {
Expand Down
Loading