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.
*/