// ───────────────────────────────────────────────────────────────────────────── // PROXY [Structural] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Provide a surrogate or placeholder for another object to control access to it. // PROBLEM : The real object is expensive to create (virtual/lazy proxy), lives elsewhere (remote proxy), // must be guarded (protection proxy), or its answers should be reused (caching proxy) — and // callers shouldn't know or care. // SAY : Participants: Subject (shared interface), RealSubject (does the work), Proxy (same interface; // holds/creates the RealSubject; decides when/whether to forward), Client. Pitch: "same // interface as the real thing; it stands in front and controls access." // SEEN IN : ES `Proxy` object (Vue 3 reactivity, MobX observables, Immer drafts); `Promise` as a placeholder // for a future value (proxy-ish); ORM lazy relations (`user.posts` loads on first access); dynamic // `import()` / lazy `require` (virtual proxy for a module); CDN & service-worker caches (caching // proxy); gRPC/REST client stubs (remote proxy); `http-proxy-middleware`. // VS : Decorator ADDS behavior and stacks freely; Proxy CONTROLS access (lazy/guard/cache/remote) and // usually manages the subject's lifecycle itself. Adapter changes the interface; Proxy keeps it. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) // Subject interface: getReport(accountId) -> { accountId, rows } let realInstances = 0; let realCalls = 0; class ReportService { // RealSubject — expensive to build and to call constructor() { realInstances += 1; } getReport(accountId) { realCalls += 1; return { accountId, rows: [accountId.length, 42] }; } } class ReportServiceProxy { // Proxy — protection + lazy + caching in one #real = null; #cache = new Map(); constructor(user) { this.user = user; } getReport(accountId) { if (!this.user.roles.includes('analyst')) throw new Error(`forbidden: ${this.user.name}`); // protection if (this.#cache.has(accountId)) return this.#cache.get(accountId); // caching this.#real ??= new ReportService(); // lazy init const report = this.#real.getReport(accountId); this.#cache.set(accountId, report); return report; } } const analyst = new ReportServiceProxy({ name: 'ada', roles: ['analyst'] }); const viewer = new ReportServiceProxy({ name: 'bob', roles: ['viewer'] }); assert.equal(realInstances, 0); // lazy: nothing built yet assert.throws(() => viewer.getReport('acct-1'), /forbidden: bob/); assert.equal(realInstances, 0); // access control blocked before any cost const r1 = analyst.getReport('acct-1'); assert.equal(realInstances, 1); // built on first authorized use assert.deepEqual(r1, { accountId: 'acct-1', rows: [6, 42] }); const r2 = analyst.getReport('acct-1'); assert.equal(r2, r1); assert.equal(realCalls, 1); // caching: second call never hit the RealSubject analyst.getReport('acct-2'); assert.equal(realCalls, 2); assert.equal(realInstances, 1); // still exactly one real instance // ── Idiomatic JS shape: the built-in Proxy object (traps = interception points) // 1) Protection/validation proxy over a config object const config = { port: 3000, env: 'dev' }; const guardedConfig = new Proxy(config, { set(target, key, value) { if (key === 'port' && !(Number.isInteger(value) && value > 0 && value < 65536)) { throw new RangeError(`invalid port ${value}`); } target[key] = value; return true; }, deleteProperty() { throw new Error('config keys cannot be deleted'); }, }); guardedConfig.port = 8080; assert.equal(config.port, 8080); // forwarded to the real target assert.throws(() => { guardedConfig.port = 70000; }, RangeError); assert.throws(() => { delete guardedConfig.env; }, /cannot be deleted/); // 2) Virtual proxy: lazy, memoized property (ORM-style lazy relation) const loads = []; const lazyUser = new Proxy({ id: 7, name: 'Ada' }, { get(target, key, receiver) { if (key === 'orders' && !('orders' in target)) { loads.push('orders'); // the "db query" happens on first access only target.orders = [{ id: 1, total: 999 }]; } return Reflect.get(target, key, receiver); }, }); assert.equal(lazyUser.name, 'Ada'); assert.deepEqual(loads, []); // untouched until someone asks assert.equal(lazyUser.orders.length, 1); assert.equal(lazyUser.orders[0].total, 999); assert.deepEqual(loads, ['orders']); // loaded exactly once // 3) Tracing proxy: observe reads (how Vue/MobX know which fields a computation depends on) const reads = new Set(); const tracked = new Proxy({ a: 1, b: 2, c: 3 }, { get: (t, k) => { reads.add(k); return t[k]; } }); assert.equal(tracked.a + tracked.c, 4); assert.deepEqual([...reads], ['a', 'c']); // dependency tracking; `b` never read console.log('ok: design-patterns/structural/proxy.js');