Skip to content

Instantly share code, notes, and snippets.

@izelnakri
Created July 23, 2026 13:30
Show Gist options
  • Select an option

  • Save izelnakri/e2bba885ab513d78cd54f3a01970ccc3 to your computer and use it in GitHub Desktop.

Select an option

Save izelnakri/e2bba885ab513d78cd54f3a01970ccc3 to your computer and use it in GitHub Desktop.
Prototype for denoland/deno#36272 — deno repl that loads a module with its exports ($) and top-level locals() live and inspectable

repl — a Deno REPL that loads a module with its exports and locals live

Prototype / proof-of-concept for denoland/deno#36272: open any module in deno repl and inspect both its exports ($) and its top-level locals (locals()) — for local paths, file:, https:, jsr:, and npm: (ESM) refs.

$ repl ./scratch.ts        # a file with top-level `const one = …, two = …`
> locals()                 # { one, two, …imports } — every top-level binding, live values
> Object.keys(locals())    # just the names

$ repl ./lib/result/index.ts
> $                        # the module's export namespace
> ok(42)                   # each export bound directly in scope

$ repl jsr:@std/semver     # remote → exports
> parse("1.2.3")

How it works

Everything a REPL needs is almost in deno repl already — this just composes the missing pieces in userland:

  • Exports come from --eval="const $ = await import(ref); Object.assign(globalThis, $)".
  • Locals can't be reflected (top-level const/let are lexical bindings, invisible to Object.keys(globalThis)), so instrument.ts parses the module with the TypeScript compiler API, enumerates every top-level value binding (const/let/var incl. destructuring, function, class, enum, namespace, and import bindings; type-only skipped), rewrites its relative import specifiers to absolute (so an instrumented copy runs from anywhere), and appends globalThis.locals = () => ({ …those bindings }). That copy is run via --eval-file.

The fact that this needs an AST pass + specifier rewriting + a temp file is the argument for it living in the runtime, where the scope is already known.

Install

# Put both files on your PATH (same dir), make the wrapper executable:
install -Dm755 repl          ~/.local/bin/repl
install -Dm644 instrument.ts ~/.local/bin/instrument.ts
repl ./some-module.ts

Requires Deno. First run fetches npm:typescript once (the parser).

Limits (inherent, not fixable in userland)

  • You only see the entry module's locals — a dependency's internals live in its own scope.
  • npm: CJS packages have no ESM top-level to enumerate → wrapper falls back to exports-only.
  • Only static relative specifiers are absolutized (import(variable) can't be).
// Given any module reference (local path, file://, http(s)://, jsr:, npm:), emit
// an *instrumented* copy of that module's source to stdout: its relative import
// specifiers rewritten to absolute (against the module's own URL, so the copy runs
// from anywhere), plus a trailer publishing `globalThis.locals()` — an object of
// every top-level value binding (const/let/var incl. destructuring, function, class,
// enum, namespace, and import bindings; type-only skipped).
//
// This is what makes locals-inspection work for ANY reference, not just local files:
// to observe a module's *non-exported* top-level bindings you must run instrumented
// source, and its imports only resolve if relative specifiers are absolutized first.
import ts from "npm:typescript@5";
const ref = Deno.args[0];
// Resolve the reference to (baseUrl, source). jsr:/npm: are located via `deno info`.
async function load(ref: string): Promise<{ base: string; source: string }> {
if (/^https?:\/\//.test(ref)) {
return { base: ref, source: await (await fetch(ref)).text() };
}
if (ref.startsWith("file://")) {
return { base: ref, source: await Deno.readTextFile(new URL(ref)) };
}
if (/^(jsr|npm):/.test(ref)) {
// Ask Deno to resolve the specifier and hand back the root module's local path.
const info = JSON.parse(
await new Deno.Command(Deno.execPath(), {
args: ["info", "--json", ref],
}).output().then((o) => new TextDecoder().decode(o.stdout)),
);
const rootSpec = info.redirects?.[info.roots[0]] ?? info.roots[0];
const root = info.modules.find((m: any) => m.specifier === rootSpec);
const local: string = root.local ?? root.emit;
return { base: root.specifier, source: await Deno.readTextFile(local) };
}
// bare local path
const url = new URL(ref, `file://${Deno.cwd()}/`).href;
return { base: url, source: await Deno.readTextFile(new URL(url)) };
}
const { base, source } = await load(ref);
const kind = /\.[tj]sx$/.test(base) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(base, source, ts.ScriptTarget.Latest, true, kind);
const names = new Set<string>();
const edits: { start: number; end: number; text: string }[] = [];
function collect(name: ts.BindingName) {
if (ts.isIdentifier(name)) names.add(name.text);
else for (const el of name.elements) {
if (ts.isBindingElement(el)) collect(el.name);
}
}
function absolutize(spec: ts.Expression | undefined) {
if (!spec || !ts.isStringLiteral(spec)) return;
const s = spec.text;
if (s.startsWith("./") || s.startsWith("../")) {
const abs = new URL(s, base).href;
edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: abs });
}
}
for (const stmt of sf.statements) {
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) collect(d.name);
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) names.add(stmt.name.text);
else if (ts.isClassDeclaration(stmt) && stmt.name) names.add(stmt.name.text);
else if (ts.isEnumDeclaration(stmt)) names.add(stmt.name.text);
else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) names.add(stmt.name.text);
else if (ts.isImportDeclaration(stmt)) {
absolutize(stmt.moduleSpecifier);
const ic = stmt.importClause;
if (ic && !ic.isTypeOnly) {
if (ic.name) names.add(ic.name.text);
const nb = ic.namedBindings;
if (nb) {
if (ts.isNamespaceImport(nb)) names.add(nb.name.text);
else for (const spec of nb.elements) if (!spec.isTypeOnly) names.add(spec.name.text);
}
}
} else if (ts.isExportDeclaration(stmt)) {
absolutize(stmt.moduleSpecifier); // re-export specifiers must resolve too
}
}
// Splice edits from the end so earlier offsets stay valid.
let out = source;
for (const e of edits.sort((a, b) => b.start - a.start)) {
out = out.slice(0, e.start) + e.text + out.slice(e.end);
}
out += `\nglobalThis.locals = () => ({ ${[...names].join(", ")} });\n`;
console.log(out);
#!/usr/bin/env bash
# repl <ref> — open a Deno REPL loaded with a module's exports ($) and locals().
# <ref> may be a local path, file://, https://, jsr:, or npm: (ESM) specifier.
# Requires instrument.ts (from this gist) on the same PATH dir, and Deno.
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "usage: repl <path | url | jsr:… | npm:…>" >&2
exit 2
fi
ref="$1"
case "$ref" in
*://*|jsr:*|npm:*|data:*) spec="$ref" ;;
/*) spec="file://$ref" ;;
*) spec="file://$PWD/$ref" ;;
esac
here="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
tmp="$(mktemp --suffix=.ts)"
trap 'rm -f "$tmp"' EXIT INT
# Instrument the source (absolutize relative imports + append a locals() capturer).
# If that can't be done (e.g. an npm CJS graph), fall back to exports-only.
if deno run -A "$here/instrument.ts" "$ref" >"$tmp" 2>/dev/null; then
exec deno repl -A \
--eval="const \$ = await import('$spec'); Object.assign(globalThis, \$);" \
--eval-file="$tmp"
else
exec deno repl -A \
--eval="const \$ = await import('$spec'); Object.assign(globalThis, \$);"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment