A live REPL: one browser page, kept open, that evaluates what you type.
The page is the point. Bindings, the DOM, timers, module state and QUnit's registry all persist between inputs, so a session is a conversation with one running document rather than a series of unrelated evaluations.
// Defined, not invoked: a real session owns a browser and a bound port. async function askOnce(session: ReplSession) { const answer = await session.eval('document.title'); await session.close(); return answer.output; }
url: string
Where the PAGE is served, e.g. http://localhost:1234 — the application, as a browser sees it.
Worth holding apart from ReplSession.inspector, which is the other address a session
has. Opening this one in a browser gets a FRESH DOCUMENT: same server, same routes, but a
second realm that shares no bindings, no DOM and no module state with the prompt. Opening
inspector gets a second VIEW of the realm the prompt is already typing into.
inspector: string | null
Where to open Chrome's DevTools on the page this session is evaluating in — the same realm,
the same DOM, the same paused frame. null where this browser has none to serve them from.
The point is that it is the SAME page rather than another one at the same address: a value
declared at the prompt is in the console there, and a debugger shows as paused in both. The
frontend is Chrome's own, served over localhost — no network, no extension.
It needs an HTTP debugging endpoint, which is what the Chrome this process pre-launched has. A
session that fell back to a browser Playwright launched talks over a pipe and gets null —
today that is macOS, where nothing is pre-launched, and any headed session, which has F12.
loaded: Array<[string, string[]]>
[file, exported names] per preloaded module — what the terminal lists on start-up.
pausedAt: string | null
Where the page is stopped at a debugger statement, or null when it is running.
While this is set, ReplSession.eval runs in the PAUSED frame, so what you type sees the locals at the breakpoint rather than the globals around it.
eval(input: string,options?: { whole?: boolean; }): Promise<ReplResult>
Evaluates one input in the page and resolves with what to print.
Tests the input registered are run before this resolves and reported as they finish, so the TAP for a test typed at the prompt lands ahead of the value — where a reader expects it.
whole says there is no more input coming. A LINE being typed can be unfinished — const a = { means keep going, and ReplResult.incomplete is how the prompt is told to. A saved
buffer cannot: you closed the editor, so an unfinished parse is a syntax error and saying
nothing about it is the worst of the three things this could do.
settled(): Promise<void>
Resolves once nothing is in flight — a no-op at the back of the evaluation queue.
What closing is awaited through. Lines pasted into a terminal arrive as one chunk and are read
as several, so a .exit at the end of a paste can otherwise reach the browser before the
evaluations above it have answered.
completions(base: string): Promise<string[]>
What could come next after base: its own properties, or everything in scope at top level
when base is ''.
What both the greyed-out suggestion and TAB are drawn from, so the two agree by construction
rather than by two lists kept in step. One caller, completion.ts, which feeds both — and
which caches these, so repl.completionCache is the cached front and this is the page itself.
It has to come from the page: the completions for document.body. are a list only the page
holds, and one assembled in Node would offer names this document does not have. Resolves
empty for anything it cannot answer — a base that is not a plain dotted path, a closed session
— because a completion is a convenience and never a reason for a prompt to report an error.
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. async function offer(session: ReplSession) { await session.completions('document'); // ['body', 'title', 'querySelector', …] return await session.completions(''); // what is in scope, including what you declared }
preview(input: string,depth?: number): Promise<string>
What the input WOULD evaluate to, or '' where it cannot be known without doing something.
depth is how far into a composite to render, for a caller that wants the whole of a value
rather than the line-long summary a prompt has room for.
V8 refuses to run anything with a side effect for this: an assignment, a declaration, a call
that mutates. That refusal is the feature, and it is why three different commands can afford
to call it — an answer offered before Enter has to be free, and deleteEverything() typed at
a prompt must not delete everything because it was typed.
The three: the right-margin preview, on every keystroke, at the default depth; .view, at a
depth deep enough to render the whole of a value; and .copy, which puts that text on the
clipboard. depth is the only thing that differs between them.
toJSON(expression: string): Promise<string | null>
What an expression is worth in the page, as JSON — null where the page cannot say.
The one way to get a VALUE back rather than a rendering of one: eval answers with what to
print, and { name: 'Ada' } printed is not {"name":"Ada"}. The expression is parenthesised
on the way out, so an object literal is an object rather than a block with a label in it.
null covers everything it cannot answer — an expression that throws, a value JSON has no
word for, a closed session — because a caller always has something else to do with the text.
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. async function bodyFor(session: ReplSession) { await session.toJSON('{ name: "Ada" }'); // '{"name":"Ada"}' await session.toJSON('new Date(0)'); // '"1970-01-01T00:00:00.000Z"' — the page's own toJSON return await session.toJSON('nothingDeclared'); // null — the page could not say }
scope(): Promise<ScopeEntry[]>
What this session has added to the page's globals — not the several hundred a browser starts
with, which is a list nobody reads. .scope.
Preloaded exports are in it too, attributed to the file they came from.
The pair to ReplSession.locals, and the two differ in what they are OF, not in how
much they show. This one is the running page's globals and answers at any time; locals() is
one stopped frame's scope chain and is empty unless the page is paused. A name can easily be
in one and not the other: let answer = 1 at the prompt is in scope() forever, while a
parameter of the function you are stopped inside is only ever in locals().
locals(): Promise<ScopeEntry[]>
What is in scope at the breakpoint, innermost first, or empty when the page is not paused.
.locals, and the frame it reads is whichever ReplSession.selectFrame last chose.
Read out of the stopped frame rather than evaluated, because a paused isolate runs nothing — asking it to would hang the one command a breakpoint exists for. That is the real difference from ReplSession.scope, which evaluates: same shape out, opposite mechanism in.
reload(): Promise<string[]>
Reloads the page and brings the modules back, resolving with the names that returned.
The code comes back; what you typed does not. Every file this session loaded is evaluated
again — the preloads because the page's own bundle re-runs them, and everything .import or
an import statement brought in because this replays those. Bindings are a different thing:
let answer = 41 is not code on disk, and bringing it back would make a reload mean "reload,
except keep my mistakes".
interrupt(): Promise<void>
Stops whatever is executing in the page — the Ctrl-C of a runaway expression.
frameSource(): Promise<{ text: string; line: number; } | null>
The source the breakpoint is in and the line it stopped on, or null when nothing is stopped.
The ORIGINAL source where there is one: a pause inside a preloaded file reports that file and its own line numbers rather than the bundle's. Typed-in functions have no file, and their source comes back from the page, which is the only place it exists.
Runs one step and stops again, reporting where — or null if the page did not stop.
into enters the next call, over runs it without entering, out runs until the current
frame returns: step, next and finish as every debugger since gdb has named them.
Stepping is also the only way INTO another frame from here. A debugger statement inside
something you call at a breakpoint does nothing, because V8 disables breakpoints for the
duration of a debugger evaluation — nothing this REPL can turn on.
The call stack where the page is stopped, innermost first — empty when nothing is.
gdb's backtrace, and the same numbering: frame 0 is where it stopped, and each one after it
is who called the one before.
addBreakpoint(location: string): Promise<Breakpoint | string>
Stops the page at file:line without a debugger statement in the source.
The line is yours, not the bundle's — it is mapped through the same source map that turns a stack frame back into a file you wrote. Lands on the nearest line at or after the one asked for, since a blank line or a comment has no code to stop on, and says which one it settled on.
import(file: string,callItThis?: string): Promise<{ name: string; names: string[]; } | string>
Brings a file into the page after the fact, the way the command line brings one in before it.
A module's exports go into scope under their own names AND together under one — ReplHelpers
for test/fixtures/repl-helpers.ts, Elixir's rule for turning a path into something typeable —
unless callItThis says what to call it. The namespace is the point: ReplHelpers at the prompt
prints everything the file has in one line, which is the question .import is usually asked
in service of.
JSON arrives parsed and anything else arrives as a string, because those are the two things a file that is not code can usefully be.
Resolves to what went into scope, or to the reason nothing did.
refresh(file: string): Promise<string[] | string | null>
Loads a file again the way it was loaded the first time, for one that has changed on disk.
A file this session has in scope and a file on disk are the same file, and an editor is how
the second one changes. Re-running it the way it came in — the same namespace, the same names
an import statement asked for — is what keeps the first one from going stale under you.
null for a file this session does not have: editing something it never loaded is editing,
and running it would be a decision nobody made.
imported(): Promise<Array<{ file: string; exports: Array<{ name: string; capture: string; }>; }>>
What each preloaded file put in scope, and what kind of thing each one is.
exports rather than names, because a list of { name, capture } is not a list of names —
and names: Array<{ name }> made every reader stop on the stutter. Not values either:
nothing here is a value, and going to the page for thirty of them to print a heading would
cost a round trip per file for something nobody asked to see.
The kind is a theme capture rather than a JavaScript type, because it exists to be COLOURED: a list of names says nothing about what they are, and painting a function the colour this REPL paints functions says it without printing a value.
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. async function listing(session: ReplSession) { return await session.imported(); // [{ file: 'test/fixtures/repl-helpers.ts', // exports: [{ name: 'double', capture: '@function' }] }] }
declaredAt(expression: string): Promise<{ file: string; line: number; } | null>
Where a value was written — the file and line of its declaration, or null for one that has
no source: a value typed at this prompt, or anything that is not a function.
V8 knows this for functions and for nothing else, so that is the honest limit of it.
The file:line header .doc and .view print, the path .open <value> hands the editor,
and what .copy attributes a copied function to — four callers, all asking the same question
a reader asks first: where is this written?
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. async function whereWritten(session: ReplSession) { await session.declaredAt('double'); // { file: 'test/fixtures/repl-helpers.ts', line: 15 } return await session.declaredAt('GREETING'); // null — a string was never written anywhere }
typeOf(expression: string): Promise<string>
What TypeScript would call the value an expression comes to — structural, from the value.
'' for anything that cannot be asked without running something, which is the same rule the
right-margin preview follows: a prompt must not change the page to answer a question about it.
whereFrom(name: string): string | null
Where a name came into this session from — the file it was imported from, or the input that
declared it. null for a name the page already had, and for anything that is not a bare name.
What ReplSession.declaredAt cannot answer: V8 knows the source of functions and of
nothing else, while this session watched every other name arrive. Without it, .doc on the
string, the number or the imported namespace you just brought in says nothing at all — which
is most of what a prompt is asked about.
Synchronous, because the session already knows: it is a lookup in what it recorded, not a question for the page.
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. function whereFrom(session: ReplSession) { session.whereFrom('GREETING'); // 'test/fixtures/repl-helpers.ts' session.whereFrom('answer'); // 'line 1' — declared at the prompt, not in a file return session.whereFrom('document'); // null — the page already had it }
breakpoints(): Breakpoint[]
The breakpoints this session has set, in the order they were set.
removeBreakpoint(index: number): Promise<boolean>
Removes one by its number. false where there is no such breakpoint.
whenPaused(listener: (where: string) => void): void
Called when the page stops with nothing waiting on it — a breakpoint reached by a timer, or by anything else the prompt did not start.
Without it such a pause is silent: the prompt keeps taking input, and every line of it evaluates in a frame nobody was told about. That is the whole reason it exists — a pause the prompt CAUSED comes back on ReplResult.pausedAt and needs no listener, so this covers only the ones that arrive on their own.
import type { ReplSession } from './session.ts'; // Defined, not invoked: a real session owns a browser and a bound port. function announce(session: ReplSession, log: (line: string) => void) { // `setTimeout(() => { debugger; }, 1000)` stops the page a second after the line answered. session.whenPaused((where) => log(`paused at ${where} — \`.continue\` to carry on`)); }
selectFrame(index: number): string | null
Reads the rest of the session in another frame of that stack, and says where it now is.
Everything follows: what you type evaluates there, ReplSession.locals is that frame's,
and ReplSession.frameSource shows the line it is stopped on. null for a frame that
is not on the stack.
continue(): Promise<void>
Lets a paused page carry on. A no-op when it is not paused.
alive(): boolean
Whether there is still a page to evaluate in.
False once the browser has gone — crashed, killed, or closed by hand. Asked of the handles rather than inferred from an error message, because a failed command means "that did not work" and this means "nothing will".
close(): Promise<void>
Closes the page, the browser and the server. Idempotent.
[[Symbol.asyncDispose]](): Promise<void>
Closes the session at the end of an await using block.