Skip to content

Instantly share code, notes, and snippets.

@bmeck
Last active December 21, 2021 15:41
Show Gist options
  • Save bmeck/fbcf80b2dbd1a0639ba53d6e75923e02 to your computer and use it in GitHub Desktop.
Save bmeck/fbcf80b2dbd1a0639ba53d6e75923e02 to your computer and use it in GitHub Desktop.

Revisions

  1. bmeck revised this gist Dec 21, 2021. 1 changed file with 8 additions and 1 deletion.
    9 changes: 8 additions & 1 deletion local-storage-example.cjs
    Original file line number Diff line number Diff line change
    @@ -53,11 +53,18 @@ 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 ' + httpContext.getStore());
    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();
  2. bmeck created this gist Dec 21, 2021.
    144 changes: 144 additions & 0 deletions local-storage-example.cjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,144 @@
    // 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 ' + httpContext.getStore());
    }
    return readFile(filepath, 'utf8');
    }

    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();