js-dojo

1 perf probe two ways

GOAL

Build the same perf probe TWICE on top of one mini Game/Player world: A) prototype-wrapping — replace Player.prototype.takeHit with a timed wrapper; uninstall puts the original back BY REFERENCE; B) Proxy-wrapping — wrap ONE player instance you hand out; a get trap serves a timed wrapper for takeHit. One shared suite has to pass against both. Two divergence checks then show they are NOT the same tool.

CONCEPTS

the whole dojo in a single file — swapping a prototype method while forwarding this/args/return (modules 1-4), Proxy get trap plus method binding (module 5), and WHY each instrumentation watches a different slice of the traffic.

HINT

(TODO 1) const original = proto[methodName]; overwrite it with a `function (...args)` (NOT an arrow — `this` must stay dynamic) that times original.apply(this, args) inside try/finally; hand back an uninstall that assigns the ORIGINAL back. (TODO 2) get trap; when key === methodName return a function that times target[methodName].apply(target, args); any OTHER function value comes back bound to the target (06-internal-slots); let everything else fall through untouched.

MIRRORS

a profiler for a game engine you don't own wraps engine.Player.prototype.takeHit / Game.prototype.render. It HAS to be prototype-wrapping: the engine dispatches those methods itself, on entities IT constructed — a Proxy you built would never sit in that call path. The WHEN-EACH-APPLIES block at the bottom spells out the split.

Run

node 01-perf-probe-two-ways.cjs   (fails until you fix the TODOs)

Source

/*
 * proto-dojo 06-capstone / 01-perf-probe-two-ways
 * -----------------------------------------------
 * GOAL: Build the same perf probe TWICE on top of one mini Game/Player
 *       world: A) prototype-wrapping — replace Player.prototype.takeHit
 *       with a timed wrapper; uninstall puts the original back BY
 *       REFERENCE; B) Proxy-wrapping — wrap ONE player instance you
 *       hand out; a get trap serves a timed wrapper for takeHit. One
 *       shared suite has to pass against both. Two divergence checks
 *       then show they are NOT the same tool.
 *
 * CONCEPTS: the whole dojo in a single file — swapping a prototype
 *           method while forwarding this/args/return (modules 1-4),
 *           Proxy get trap plus method binding (module 5), and WHY each
 *           instrumentation watches a different slice of the traffic.
 *
 * HINT: (TODO 1) const original = proto[methodName]; overwrite it with
 *       a `function (...args)` (NOT an arrow — `this` must stay
 *       dynamic) that times original.apply(this, args) inside
 *       try/finally; hand back an uninstall that assigns the ORIGINAL
 *       back. (TODO 2) get trap; when key === methodName return a
 *       function that times target[methodName].apply(target, args); any
 *       OTHER function value comes back bound to the target
 *       (06-internal-slots); let everything else fall through
 *       untouched.
 *
 * MIRRORS: a profiler for a game engine you don't own wraps
 *          engine.Player.prototype.takeHit / Game.prototype.render. It
 *          HAS to be prototype-wrapping: the engine dispatches those
 *          methods itself, on entities IT constructed — a Proxy you
 *          built would never sit in that call path. The
 *          WHEN-EACH-APPLIES block at the bottom spells out the split.
 *
 * Run: node 01-perf-probe-two-ways.cjs   (fails until you fix the TODOs)
 */
'use strict';
const assert = require('node:assert');

/* ---------------- mini engine-shaped world ------------------------- */
class MiniGame {
    constructor(settings) {
        this.settings = settings;
        this.renderCount = 0;
        this.players = [];
        for (const p of settings.players || []) this.addPlayer(p, false);
    }
    addPlayer(playerSettings, render = true) {
        const p = new MiniPlayer(this, playerSettings);
        this.players.push(p);
        if (render) this.render();
        return p;
    }
    render() { this.renderCount++; }
}

class MiniPlayer {
    constructor(game, settings) {
        this.game = game;
        this.settings = settings;
        this.hits = [...(settings.hits || [])];
    }
    takeHit(hit, render = true) {
        this.hits.push(hit);
        if (render) this.game.render();
        return this; // chainable, like the real engine
    }
    loadHits(hits, render = true) {
        this.hits.length = 0;
        for (const h of hits) this.takeHit(h, false); // INTERNAL calls via `this`
        if (render) this.game.render();
    }
}

const makeStats = () => ({ calls: 0, totalMs: 0 });

/* ------------------------------------------------------------------ */
/* TODO 1: prototype probe. Wrap proto[methodName] in place; return an  */
/*         uninstall() that restores the captured original.             */
/* ------------------------------------------------------------------ */
function installPrototypeProbe(proto, methodName, stats) {
    // TODO 1: swap in a timing wrapper (keep `this`, args, return value)
    return function uninstall() {
        // TODO 1: put the ORIGINAL back — by reference
    };
}

/* ------------------------------------------------------------------ */
/* TODO 2: proxy probe. Return a Proxy of `instance` whose get trap     */
/*         returns a timing wrapper for methodName (applied ON the      */
/*         target), binds other functions to the target, and forwards   */
/*         everything else.                                             */
/* ------------------------------------------------------------------ */
function proxyProbe(instance, methodName, stats) {
    // TODO 2: return new Proxy(instance, { get(target, key) { ... } })
    return instance;
}

/* --------------- shared suite: must pass on BOTH ------------------- */
function runSharedSuite(label, makeProbed) {
    const game = new MiniGame({ players: [{ id: 'knight', hits: [] }] });
    const { handle, stats, teardown } = makeProbed(game);

    for (let i = 0; i < 5; i++) handle.takeHit({ at: i * 16, damage: 5 + i }, false);
    assert.strictEqual(stats.calls, 5,
        `${label}: counted ${stats.calls}/5 takeHit calls through the handle — ` +
        'the probe is not seeing the traffic');
    assert.ok(Number.isFinite(stats.totalMs) && stats.totalMs >= 0,
        `${label}: totalMs must accumulate real durations`);
    assert.strictEqual(game.players[0].hits.length, 5,
        `${label}: the probe must be transparent — hits must still land`);

    const ret = handle.takeHit({ at: 999, damage: 99 }, false);
    assert.strictEqual(ret, game.players[0],
        `${label}: takeHit must still return the player for chaining ` +
        '(forward the return value; proxy version: apply on the TARGET)');
    assert.strictEqual(stats.calls, 6, `${label}: the chained call counts too`);

    teardown();
    console.log(`  shared suite passed [${label}] — ${stats.calls} calls, ` +
        `${stats.totalMs.toFixed(3)} ms`);
}

runSharedSuite('prototype-probe (TODO 1)', (game) => {
    const stats = makeStats();
    const uninstall = installPrototypeProbe(MiniPlayer.prototype, 'takeHit', stats);
    return { handle: game.players[0], stats, teardown: uninstall };
});

runSharedSuite('proxy-probe (TODO 2)', (game) => {
    const stats = makeStats();
    const handle = proxyProbe(game.players[0], 'takeHit', stats);
    return { handle, stats, teardown() {} };
});

/* ------------- divergence 1: internal calls ------------------------ */
// loadHits fires this.takeHit 3x from INSIDE the player. The prototype probe
// catches them (internal dispatch walks the prototype); once uninstall runs,
// the prototype has to hold the very same function object it started with.
{
    const game = new MiniGame({ players: [{ hits: [] }] });
    const stats = makeStats();
    const originalRef = MiniPlayer.prototype.takeHit;
    const uninstall = installPrototypeProbe(MiniPlayer.prototype, 'takeHit', stats);

    game.players[0].loadHits([
        { at: 0, damage: 5 }, { at: 16, damage: 6 }, { at: 32, damage: 7 },
    ]);
    assert.strictEqual(stats.calls, 3,
        'TODO 1: the prototype probe must count INTERNAL loadHits->this.takeHit ' +
        'calls — that is its superpower');

    uninstall();
    assert.strictEqual(MiniPlayer.prototype.takeHit, originalRef,
        'TODO 1: uninstall must restore the ORIGINAL function by reference, ' +
        'not a lookalike — other patchers may hold references');
    game.players[0].takeHit({ at: 48, damage: 8 }, false);
    assert.strictEqual(stats.calls, 3, 'after uninstall, no more counting');
}

/* ------------- divergence 2: the proxy is blind -------------------- */
// The "engine" (our MiniGame) keeps the RAW player and dispatches on it
// directly. The proxy can only observe calls that travel through the
// handle YOU gave out.
{
    const game = new MiniGame({ players: [{ hits: [] }] });
    const stats = makeStats();
    const handle = proxyProbe(game.players[0], 'takeHit', stats);

    handle.takeHit({ at: 0, damage: 5 }, false);
    handle.takeHit({ at: 16, damage: 6 }, false);
    assert.strictEqual(stats.calls, 2, 'calls through the handle are seen');

    game.players[0].loadHits([
        { at: 0, damage: 5 }, { at: 16, damage: 6 },
        { at: 32, damage: 7 }, { at: 48, damage: 8 },
    ]);
    assert.strictEqual(stats.calls, 2,
        'the engine used its own RAW reference — the proxy probe must NOT ' +
        'have seen those 4 internal takeHit calls. If your count went up, ' +
        'you patched the shared prototype inside proxyProbe — do not.');
    assert.strictEqual(game.players[0].hits.length, 4,
        'the real player took the hits regardless');
}

console.log('OK 01-perf-probe-two-ways — same suite, two instrumentations, ' +
    'two different fields of view');

/* ------------------------------------------------------------------ */
/* WHEN EACH APPLIES                                                    */
/* ------------------------------------------------------------------ */
/*
 * PROTOTYPE-WRAPPING (engine.wrap style)
 *   - Catches EVERY call on EVERY instance, existing or not yet spawned,
 *     including the engine's own internal dispatch — consumers (and the
 *     engine itself) reach takeHit by walking the prototype chain.
 *   - That is what makes it THE technique for profiling an engine: you never
 *     constructed those Player objects and never hand them out; the shared
 *     prototype is the only thing you control.
 *   - It is global, mutable state: stacked probes have to compose (each
 *     layer captures whatever it found in the slot), so uninstall order
 *     matters. Always put the captured reference back, never a copy.
 *   - Bonus (see capstone 02): patching the BASE Player.prototype also
 *     catches Archer/Mage instances, because subclasses inherit takeHit
 *     instead of shadowing it.
 *
 * PROXY-WRAPPING
 *   - Watches ONLY what travels through the wrapped reference; internal
 *     calls, other references, and anything created before or after stay
 *     invisible.
 *   - Ideal at MODULE BOUNDARIES: objects YOU construct and distribute (an
 *     input-feed API, a plugin surface, a settings object) — per-consumer
 *     stats, revocable access (module 5), zero global mutation.
 *   - Trade-offs: proxy and target are two different identities (a Map or
 *     Set keyed on the object misses), #private fields and internal slots
 *     want the bind fix, and any method that returns `this` hands the raw
 *     target straight past the wrapper.
 *
 * Rule of thumb: code you DON'T own gets instrumented at the prototype;
 * objects you DO own and hand out get a proxy.
 */

Solution