Last active
December 21, 2021 15:41
-
-
Save bmeck/fbcf80b2dbd1a0639ba53d6e75923e02 to your computer and use it in GitHub Desktop.
https://twitter.com/bradleymeck/status/1470586787047395331 , revised form of https://gist.github.com/bmeck/b8044a739231c389457f2ab3aa7ffffa post a regression fix
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// node [--trace-gc] local-storage-exmaple.cjs | |
// creates a server | |
// will drop /favicon.ico connections poorly without properly closing them | |
// `cleanupContext` AsyncLocalStorage + `cleanup` FinalizationRegistry will clean up | |
// will track the http request URL for errors in httpContext | |
// will show up in error generated for bad permissions | |
// will grant permissions in permissionsContext based upon query params / search params | |
// need ?fs=true to make / respond with a 200 | |
// | |
// goto /?fs=true in browser to see it work | |
// goto /?fs=false in browser to see it error | |
// browsers will automatically request /favicon.ico | |
'use strict'; | |
const { AsyncLocalStorage } = require('async_hooks'); | |
const http = require('http'); | |
const events = require('events'); | |
const { readFile } = require('fs/promises'); | |
// tracking what permissions we have | |
const permissionsContext = new AsyncLocalStorage(); | |
// tracking which HTTP context we are in | |
const httpContext = new AsyncLocalStorage(); | |
const getSearchParams = (reqUrl) => { | |
const url = new URL(reqUrl, 'http://invalid.invalid/'); | |
return url.searchParams; | |
} | |
/** | |
* FOR DEMO PURPOSES ONLY, NEVER DO THIS | |
* Use real auth methods and not search params | |
* @param req | |
*/ | |
async function auth(req, fn) { | |
// NO NO NO NO NO | |
let permissions = {}; | |
for (const [key, value] of getSearchParams(req.url).entries()) { | |
permissions[key] = value; | |
} | |
permissionsContext.run(permissions, fn); | |
} | |
function hasPermission(key) { | |
return permissionsContext.getStore()?.[key] === 'true'; | |
} | |
/** | |
* needs ?fs=true | |
* @param filepath | |
* @returns | |
*/ | |
function gatedReadFile(filepath) { | |
// has no reference to how auth is obtained/propagated | |
if (!hasPermission('fs')) { | |
// has no reference to the request / no need to propagate it | |
throw new Error('403'); | |
} | |
return readFile(filepath, 'utf8'); | |
} | |
process.on('uncaughtExceptionMonitor', function (err) { | |
let reqUrl = httpContext.getStore(); | |
if (reqUrl) { | |
console.error('Error from URL %s', reqUrl); | |
} | |
}); | |
const cleanup = new FinalizationRegistry(([req, res, id, cleanup]) => { | |
console.log('closing dropped connection to ', req.url, 'with cleanup handler id', id); | |
cleanup(); | |
try { | |
res.writeHead(500); | |
res.end(); | |
} catch { | |
// this is normal | |
} | |
}); | |
// Garbage generator to keep GC running periodically | |
setInterval(() => [].concat(1), 0); | |
setInterval(() => [].concat(1), 0); | |
setInterval(() => [].concat(1), 0); | |
setInterval(() => [].concat(1), 0); | |
let gcId = 1; | |
const cleanupContext = new AsyncLocalStorage(); | |
/** | |
* @param {import('http').IncomingMessage} req | |
* @param {import('http').ServerResponse} res | |
* @returns | |
*/ | |
function setCleanup(req, res, fn) { | |
let id = gcId++; | |
// {id} is our "token" | |
// don't directly hold onto {id}, it won't be able to GC then | |
let GC = new WeakRef({id}); | |
// data to associate with the token | |
let held = [req, res, id, () => { | |
res.off('finish', unregister); | |
}]; | |
console.log(req.url, 'assigned cleanup handler', id); | |
function unregister() { | |
console.log(req.url, 'unregistered cleanup handler', id); | |
cleanup.unregister(GC.deref()); | |
} | |
// setup when the token deallocs, fire handler | |
cleanup.register(GC.deref(), held, GC.deref()); | |
res.on('finish', unregister); | |
// Force the token to stay alive for all async work spawned from the current task | |
cleanupContext.run(GC.deref(), fn); | |
} | |
/** | |
* @param {import('http').IncomingMessage} req | |
* @param {import('http').ServerResponse} res | |
* @returns | |
*/ | |
function handleHTTP(req, res) { | |
setCleanup(req, res, () => { | |
if (req.url === '/favicon.ico') { | |
// uh oh, forgot to close (don't worry the Finalization Registry handles it) | |
setTimeout(() => { | |
// keep the req, res alive WAAAAY too long | |
// should see a GC entry before this | |
console.log('can cleanup /favicon.ico') | |
}, 0); | |
return; | |
} | |
httpContext.run(req.url, () => { | |
auth(req, () => { | |
console.log('%s %s', req.method, req.url); | |
// "simulate" a work queue | |
setTimeout(doWork, 1, res); | |
}); | |
}); | |
}); | |
} | |
async function doWork(res) { | |
res.end(await gatedReadFile(__filename)); | |
} | |
async function main() { | |
const server = http.createServer(handleHTTP).listen(process.env.PORT || 0) | |
await events.once(server, 'listening'); | |
console.log('Listening on', server.address()); | |
console.log(`http://127.0.0.1:${server.address().port}`) | |
} | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment