conjure-js 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist-cli/conjure-js.mjs +9336 -5028
  2. package/dist-vite-plugin/index.mjs +10455 -0
  3. package/package.json +9 -2
  4. package/src/bin/cli.ts +2 -2
  5. package/src/bin/nrepl-symbol.ts +150 -0
  6. package/src/bin/nrepl.ts +301 -157
  7. package/src/bin/version.ts +1 -1
  8. package/src/clojure/core.clj +764 -29
  9. package/src/clojure/core.clj.d.ts +76 -4
  10. package/src/clojure/demo/math.clj +5 -1
  11. package/src/clojure/generated/builtin-namespace-registry.ts +4 -0
  12. package/src/clojure/generated/clojure-core-source.ts +765 -29
  13. package/src/clojure/generated/clojure-set-source.ts +136 -0
  14. package/src/clojure/generated/clojure-walk-source.ts +72 -0
  15. package/src/clojure/set.clj +132 -0
  16. package/src/clojure/set.clj.d.ts +20 -0
  17. package/src/clojure/string.clj.d.ts +14 -0
  18. package/src/clojure/walk.clj +68 -0
  19. package/src/clojure/walk.clj.d.ts +7 -0
  20. package/src/core/assertions.ts +114 -6
  21. package/src/core/bootstrap.ts +337 -0
  22. package/src/core/conversions.ts +48 -31
  23. package/src/core/core-module.ts +303 -0
  24. package/src/core/env.ts +42 -7
  25. package/src/core/errors.ts +8 -0
  26. package/src/core/evaluator/apply.ts +40 -25
  27. package/src/core/evaluator/arity.ts +8 -8
  28. package/src/core/evaluator/async-evaluator.ts +565 -0
  29. package/src/core/evaluator/collections.ts +30 -4
  30. package/src/core/evaluator/destructure.ts +180 -69
  31. package/src/core/evaluator/dispatch.ts +24 -14
  32. package/src/core/evaluator/evaluate.ts +22 -20
  33. package/src/core/evaluator/expand.ts +45 -15
  34. package/src/core/evaluator/form-parsers.ts +178 -0
  35. package/src/core/evaluator/index.ts +7 -9
  36. package/src/core/evaluator/js-interop.ts +189 -0
  37. package/src/core/evaluator/quasiquote.ts +14 -8
  38. package/src/core/evaluator/recur-check.ts +6 -6
  39. package/src/core/evaluator/special-forms.ts +380 -173
  40. package/src/core/factories.ts +182 -3
  41. package/src/core/index.ts +55 -5
  42. package/src/core/module.ts +136 -0
  43. package/src/core/ns-forms.ts +107 -0
  44. package/src/core/positions.ts +9 -2
  45. package/src/core/printer.ts +371 -11
  46. package/src/core/reader.ts +127 -29
  47. package/src/core/registry.ts +209 -0
  48. package/src/core/runtime.ts +376 -0
  49. package/src/core/session.ts +263 -478
  50. package/src/core/stdlib/arithmetic.ts +516 -215
  51. package/src/core/stdlib/async-fns.ts +132 -0
  52. package/src/core/stdlib/atoms.ts +286 -63
  53. package/src/core/stdlib/errors.ts +54 -50
  54. package/src/core/stdlib/hof.ts +74 -173
  55. package/src/core/stdlib/js-namespace.ts +344 -0
  56. package/src/core/stdlib/lazy.ts +34 -0
  57. package/src/core/stdlib/maps-sets.ts +322 -0
  58. package/src/core/stdlib/meta.ts +109 -28
  59. package/src/core/stdlib/predicates.ts +322 -196
  60. package/src/core/stdlib/regex.ts +126 -98
  61. package/src/core/stdlib/seq.ts +564 -0
  62. package/src/core/stdlib/strings.ts +164 -135
  63. package/src/core/stdlib/transducers.ts +95 -100
  64. package/src/core/stdlib/utils.ts +283 -147
  65. package/src/core/stdlib/vars.ts +27 -27
  66. package/src/core/stdlib/vectors.ts +122 -0
  67. package/src/core/tokenizer.ts +13 -3
  68. package/src/core/transformations.ts +117 -9
  69. package/src/core/types.ts +118 -6
  70. package/src/host/node-host-module.ts +74 -0
  71. package/src/nrepl/relay.ts +432 -0
  72. package/src/vite-plugin-clj/codegen.ts +87 -95
  73. package/src/vite-plugin-clj/index.ts +242 -18
  74. package/src/vite-plugin-clj/namespace-utils.ts +39 -0
  75. package/src/vite-plugin-clj/static-analysis.ts +211 -0
  76. package/src/clojure/demo.clj +0 -63
  77. package/src/clojure/demo.clj.d.ts +0 -0
  78. package/src/core/core-env.ts +0 -60
  79. package/src/core/stdlib/collections.ts +0 -784
  80. package/src/host/node.ts +0 -55
package/src/bin/nrepl.ts CHANGED
@@ -5,17 +5,24 @@ import { BDecoderStream, BEncoderStream } from './bencode'
5
5
  import {
6
6
  createSession,
7
7
  createSessionFromSnapshot,
8
+ cljNil,
8
9
  printString,
10
+ readString,
9
11
  snapshotSession,
10
- type CljMap,
11
12
  type CljValue,
12
13
  type Session,
13
14
  type SessionSnapshot,
14
15
  } from '../core'
15
- import { tryLookup, lookupVar, getNamespaceEnv } from '../core/env'
16
+ import { withPrintContext } from '../core/printer'
17
+ import { derefValue } from '../core/env'
16
18
  import { inferSourceRoot } from './nrepl-utils'
19
+ import {
20
+ resolveSymbol as resolveSymbolShared,
21
+ extractMeta as extractMetaShared,
22
+ } from './nrepl-symbol'
17
23
  import { VERSION } from './version'
18
- import { injectNodeHostFunctions } from '../host/node'
24
+ export { makeNodeHostModule } from '../host/node-host-module'
25
+ import { makeNodeHostModule } from '../host/node-host-module'
19
26
 
20
27
  const CONJURE_VERSION = VERSION
21
28
 
@@ -25,6 +32,22 @@ const CONJURE_VERSION = VERSION
25
32
 
26
33
  type NreplMessage = Record<string, unknown>
27
34
 
35
+ /** Streaming output chunk forwarded from a remote eval. */
36
+ type RemoteStreamChunk =
37
+ | { type: 'out'; text: string }
38
+ | { type: 'err'; text: string }
39
+
40
+ /** Minimal interface satisfied by MeshNode (structural — no import from the mesh experiment). */
41
+ export type RemoteEvalNode = {
42
+ evalAt(
43
+ targetId: string,
44
+ source: string,
45
+ ns?: string,
46
+ timeoutMs?: number,
47
+ onChunk?: (chunk: RemoteStreamChunk) => void
48
+ ): Promise<{ value?: string; error?: string }>
49
+ }
50
+
28
51
  type ManagedSession = {
29
52
  id: string
30
53
  session: Session
@@ -46,19 +69,23 @@ function createManagedSession(
46
69
  id: string,
47
70
  snapshot: SessionSnapshot,
48
71
  encoder: BEncoderStream,
49
- sourceRoots?: string[]
72
+ sourceRoots?: string[],
73
+ onOutput?: (text: string) => void,
74
+ importModule?: (specifier: string) => unknown | Promise<unknown>
50
75
  ): ManagedSession {
51
76
  let currentMsgId = ''
52
77
 
53
78
  const session = createSessionFromSnapshot(snapshot, {
54
79
  output: (text) => {
55
80
  send(encoder, { id: currentMsgId, session: id, out: text })
81
+ onOutput?.(text)
56
82
  },
57
83
  readFile: (filePath) => readFileSync(filePath, 'utf8'),
58
84
  sourceRoots,
85
+ importModule,
59
86
  })
60
87
 
61
- injectNodeHostFunctions(session)
88
+ session.runtime.installModules([makeNodeHostModule(session)])
62
89
 
63
90
  return {
64
91
  id,
@@ -105,11 +132,13 @@ function handleClone(
105
132
  sessions: Map<string, ManagedSession>,
106
133
  snapshot: SessionSnapshot,
107
134
  encoder: BEncoderStream,
108
- sourceRoots?: string[]
135
+ sourceRoots?: string[],
136
+ onOutput?: (text: string) => void,
137
+ importModule?: (specifier: string) => unknown | Promise<unknown>
109
138
  ) {
110
139
  const id = (msg['id'] as string) ?? ''
111
140
  const newId = makeSessionId()
112
- const managed = createManagedSession(newId, snapshot, encoder, sourceRoots)
141
+ const managed = createManagedSession(newId, snapshot, encoder, sourceRoots, onOutput, importModule)
113
142
  sessions.set(newId, managed)
114
143
  done(encoder, id, undefined, { 'new-session': newId })
115
144
  }
@@ -135,20 +164,148 @@ function handleDescribe(msg: NreplMessage, encoder: BEncoderStream) {
135
164
  })
136
165
  }
137
166
 
138
- function handleEval(
167
+ async function handleEval(
139
168
  msg: NreplMessage,
140
169
  managed: ManagedSession,
141
- encoder: BEncoderStream
142
- ) {
170
+ encoder: BEncoderStream,
171
+ meshNode?: RemoteEvalNode
172
+ ): Promise<void> {
143
173
  const id = (msg['id'] as string) ?? ''
144
174
  const code = (msg['code'] as string) ?? ''
145
175
 
146
176
  managed.currentMsgId = id
147
177
 
178
+ // Calva sends 1-based :line and :column for the start of the evaluated form
179
+ // in the original file. Convert to 0-based offsets so evaluateDef can add
180
+ // them to the relative positions the reader computes from the snippet.
181
+ const lineOffset =
182
+ typeof msg['line'] === 'number' ? (msg['line'] as number) - 1 : 0
183
+ const colOffset =
184
+ typeof msg['column'] === 'number' ? (msg['column'] as number) - 1 : 0
185
+
186
+ // Mesh routing: if mesh/*eval-target* is set and a meshNode is provided, route remotely.
187
+ // *eval-target* stores a CljString (set by set-target!) or CljNil (cleared).
188
+ //
189
+ // IMPORTANT: set-target! must ALWAYS run locally, regardless of the current target.
190
+ // If routed, (mesh/set-target! nil) would run on the remote node and clear ITS var,
191
+ // leaving the local target set — the user would be permanently stuck until reconnect.
192
+ //
193
+ // We detect "is this a set-target! call?" by resolving the head symbol in the local
194
+ // environment, not by regex. This correctly handles namespace aliases (:as m → m/set-target!),
195
+ // refers, and user renames — without any string matching on code.
196
+ // Vars that must always evaluate locally — reading or calling them on a
197
+ // remote node would return that node's state, not the local connection's.
198
+ const MESH_LOCAL_ONLY = new Set(['set-target!', '*eval-target*'])
199
+ const isMeshControl = (() => {
200
+ try {
201
+ const first = readString(code.trim())
202
+ // Direct symbol read: mesh/*eval-target*
203
+ if (first.kind === 'symbol') {
204
+ const resolved = resolveSymbol(first.name, managed)
205
+ return resolved?.resolvedNs === 'mesh' && MESH_LOCAL_ONLY.has(resolved.localName)
206
+ }
207
+ // Function call: (set-target! ...) or (mesh/set-target! ...)
208
+ if (first.kind !== 'list' || first.value.length === 0) return false
209
+ const head = first.value[0]
210
+ if (head.kind !== 'symbol') return false
211
+ const resolved = resolveSymbol(head.name, managed)
212
+ return resolved?.resolvedNs === 'mesh' && MESH_LOCAL_ONLY.has(resolved?.localName ?? '')
213
+ } catch {
214
+ return false
215
+ }
216
+ })()
217
+
218
+ if (!isMeshControl && meshNode) {
219
+ const meshNs = managed.session.getNs('mesh')
220
+ const evalTargetVar = meshNs?.vars.get('*eval-target*')
221
+ const targetVal = evalTargetVar?.value
222
+ if (targetVal?.kind === 'string' && targetVal.value) {
223
+ const targetId = targetVal.value
224
+ try {
225
+ const result = await meshNode.evalAt(
226
+ targetId,
227
+ code,
228
+ managed.session.currentNs,
229
+ undefined,
230
+ (chunk) => {
231
+ // Stream each chunk back to the editor immediately as it arrives —
232
+ // no buffering, works for million-line outputs and long-running evals.
233
+ if (chunk.type === 'out')
234
+ send(encoder, { id, session: managed.id, out: chunk.text })
235
+ else send(encoder, { id, session: managed.id, err: chunk.text })
236
+ }
237
+ )
238
+ if (result.error) {
239
+ done(encoder, id, managed.id, {
240
+ ex: result.error,
241
+ err: result.error + '\n',
242
+ ns: managed.session.currentNs,
243
+ status: ['eval-error', 'done'],
244
+ })
245
+ } else {
246
+ done(encoder, id, managed.id, {
247
+ value: result.value ?? 'nil',
248
+ ns: managed.session.currentNs,
249
+ })
250
+ }
251
+ } catch (e) {
252
+ const msg2 = e instanceof Error ? e.message : String(e)
253
+ const isUnreachable =
254
+ msg2.includes('not registered') || msg2.includes('Timeout')
255
+ if (isUnreachable) {
256
+ // Clear *eval-target* so the user isn't stuck on subsequent evals.
257
+ // Do NOT fall through to local eval — the code was intended for the
258
+ // remote node; running it locally could be dangerous or wrong.
259
+ const meshNsClear = managed.session.getNs('mesh')
260
+ const evalTargetVarClear = meshNsClear?.vars.get('*eval-target*')
261
+ if (evalTargetVarClear) evalTargetVarClear.value = cljNil()
262
+
263
+ const errMsg = `Node '${targetId}' unreachable — *eval-target* cleared. Eval dropped. Re-send to try on this node or try another node.\n`
264
+ done(encoder, id, managed.id, {
265
+ ex: errMsg,
266
+ err: errMsg,
267
+ ns: managed.session.currentNs,
268
+ status: ['eval-error', 'done'],
269
+ })
270
+ } else {
271
+ done(encoder, id, managed.id, {
272
+ ex: msg2,
273
+ err: msg2 + '\n',
274
+ ns: managed.session.currentNs,
275
+ status: ['eval-error', 'done'],
276
+ })
277
+ }
278
+ }
279
+ return
280
+ }
281
+ }
282
+
148
283
  try {
149
- const result = managed.session.evaluate(code)
284
+ // evaluateAsync awaits any CljPending result before returning, so async
285
+ // forms, mesh/set-target!, mesh/list-nodes, etc. all resolve correctly
286
+ // before the nREPL response is sent. Sync evals are unaffected.
287
+ const result = await managed.session.evaluateAsync(code, {
288
+ lineOffset,
289
+ colOffset,
290
+ })
291
+ // Resolve *print-length* / *print-level* via the registry (same rationale
292
+ // as emitToOut in core-module.ts: session.getNs goes through the runtime
293
+ // registry so we always get the session's own freshly-cloned var, not a
294
+ // stale one from a snapshot closure env).
295
+ const coreNs = managed.session.getNs('clojure.core')
296
+ const lenVar = coreNs?.vars.get('*print-length*')
297
+ const lvlVar = coreNs?.vars.get('*print-level*')
298
+ const printLen = lenVar ? derefValue(lenVar) : undefined
299
+ const printLvl = lvlVar ? derefValue(lvlVar) : undefined
300
+ const resultStr = withPrintContext(
301
+ {
302
+ printLength: printLen?.kind === 'number' ? printLen.value : null,
303
+ printLevel: printLvl?.kind === 'number' ? printLvl.value : null,
304
+ },
305
+ () => printString(result)
306
+ )
150
307
  done(encoder, id, managed.id, {
151
- value: printString(result),
308
+ value: resultStr,
152
309
  ns: managed.session.currentNs,
153
310
  })
154
311
  } catch (error) {
@@ -162,11 +319,11 @@ function handleEval(
162
319
  }
163
320
  }
164
321
 
165
- function handleLoadFile(
322
+ async function handleLoadFile(
166
323
  msg: NreplMessage,
167
324
  managed: ManagedSession,
168
325
  encoder: BEncoderStream
169
- ) {
326
+ ): Promise<void> {
170
327
  const id = (msg['id'] as string) ?? ''
171
328
  const source = (msg['file'] as string) ?? ''
172
329
  const fileName = (msg['file-name'] as string) ?? ''
@@ -187,7 +344,14 @@ function handleLoadFile(
187
344
 
188
345
  const nsHint =
189
346
  fileName.replace(/\.clj$/, '').replace(/\//g, '.') || undefined
190
- const loadedNs = managed.session.loadFile(source, nsHint)
347
+
348
+ // loadFileAsync handles both sync and string (:require ["pkg" :as X]) forms.
349
+ // It falls back to the sync path internally if no async requires are present.
350
+ const loadedNs = await managed.session.loadFileAsync(
351
+ source,
352
+ nsHint,
353
+ filePath || undefined
354
+ )
191
355
 
192
356
  // Track the file path for this namespace so info/lookup can return :file
193
357
  // for go-to-definition support.
@@ -248,120 +412,15 @@ function resolveSymbol(
248
412
  sym: string,
249
413
  managed: ManagedSession,
250
414
  contextNs?: string
251
- ): { value: CljValue; resolvedNs: string; localName: string } | null {
252
- const ns = contextNs ?? managed.session.currentNs
253
- const slashIdx = sym.indexOf('/')
254
-
255
- if (slashIdx > 0) {
256
- const qualifier = sym.slice(0, slashIdx)
257
- const localName = sym.slice(slashIdx + 1)
258
-
259
- // 1. Try as full namespace name (use registry Env for chain-based lookup)
260
- const nsEnvFull = managed.session.registry.get(qualifier)
261
- if (nsEnvFull) {
262
- const value = tryLookup(localName, nsEnvFull)
263
- if (value !== undefined) return { value, resolvedNs: qualifier, localName }
264
- }
265
-
266
- // 2. Try as alias (:as str → clojure.string)
267
- const currentNsData = managed.session.getNs(ns)
268
- const aliasedNs = currentNsData?.aliases.get(qualifier)
269
- if (aliasedNs) {
270
- const v = aliasedNs.vars.get(localName)
271
- if (v !== undefined)
272
- return { value: v.value, resolvedNs: aliasedNs.name, localName }
273
- }
274
-
275
- return null
276
- }
277
-
278
- // Unqualified symbol
279
- const localName = sym
280
- const nsEnvFull = managed.session.registry.get(ns)
281
- if (!nsEnvFull) return null
282
- const value = tryLookup(sym, nsEnvFull)
283
- if (value === undefined) return null
284
-
285
- // Determine the namespace where this symbol is defined.
286
- // CljVar carries the authoritative ns; fall back to other heuristics.
287
- const varObj = lookupVar(sym, nsEnvFull)
288
- let resolvedNs: string
289
- if (varObj) {
290
- resolvedNs = varObj.ns
291
- } else if (value.kind === 'function' || value.kind === 'macro') {
292
- resolvedNs = getNamespaceEnv(value.env).ns?.name ?? ns
293
- } else if (value.kind === 'native-function') {
294
- const i = value.name.indexOf('/')
295
- resolvedNs = i > 0 ? value.name.slice(0, i) : ns
296
- } else {
297
- resolvedNs = ns
298
- }
299
-
300
- return { value, resolvedNs, localName }
415
+ ) {
416
+ return resolveSymbolShared(sym, managed.session, contextNs)
301
417
  }
302
418
 
303
- function extractMeta(value: CljValue): {
304
- doc: string
305
- arglistsStr: string
306
- eldocArgs: string[][] | null
307
- type: string
308
- } {
309
- const type =
310
- value.kind === 'macro'
311
- ? 'macro'
312
- : value.kind === 'function' || value.kind === 'native-function'
313
- ? 'function'
314
- : 'var'
315
-
316
- const meta: CljMap | undefined =
317
- value.kind === 'function'
318
- ? value.meta
319
- : value.kind === 'native-function'
320
- ? value.meta
321
- : undefined
322
-
323
- let doc = ''
324
- let arglistsStr = ''
325
- let eldocArgs: string[][] | null = null
326
-
327
- if (meta) {
328
- const docEntry = meta.entries.find(
329
- ([k]) => k.kind === 'keyword' && k.name === ':doc'
330
- )
331
- if (docEntry && docEntry[1].kind === 'string') doc = docEntry[1].value
332
-
333
- const argsEntry = meta.entries.find(
334
- ([k]) => k.kind === 'keyword' && k.name === ':arglists'
335
- )
336
- if (argsEntry && argsEntry[1].kind === 'vector') {
337
- const arglists = argsEntry[1]
338
- arglistsStr = '(' + arglists.value.map((al) => printString(al)).join(' ') + ')'
339
- eldocArgs = arglists.value.map((al) => {
340
- if (al.kind !== 'vector') return [printString(al)]
341
- return al.value.map((p) => (p.kind === 'symbol' ? p.name : printString(p)))
342
- })
343
- }
344
- }
345
-
346
- // Fallback: derive arglists from structural arities (fn/macro without meta)
347
- if (
348
- arglistsStr === '' &&
349
- (value.kind === 'function' || value.kind === 'macro')
350
- ) {
351
- const arityStrs = value.arities.map((arity) => {
352
- const params = arity.params.map((p) => printString(p))
353
- if (arity.restParam) params.push('&', printString(arity.restParam))
354
- return '[' + params.join(' ') + ']'
355
- })
356
- arglistsStr = '(' + arityStrs.join(' ') + ')'
357
- eldocArgs = value.arities.map((arity) => {
358
- const params = arity.params.map((p) => printString(p))
359
- if (arity.restParam) params.push('&', printString(arity.restParam))
360
- return params
361
- })
362
- }
363
-
364
- return { doc, arglistsStr, eldocArgs, type }
419
+ function extractMeta(resolved: {
420
+ value: CljValue
421
+ varObj?: import('./nrepl-symbol').ResolvedSymbol['varObj']
422
+ }) {
423
+ return extractMetaShared(resolved.value, resolved.varObj?.meta)
365
424
  }
366
425
 
367
426
  function handleInfo(
@@ -395,15 +454,30 @@ function handleInfo(
395
454
  return
396
455
  }
397
456
 
398
- const meta = extractMeta(resolved.value)
457
+ const meta = extractMeta(resolved)
399
458
  const file = managed.nsToFile.get(resolved.resolvedNs)
459
+
460
+ // Extract :line/:column/:file from var meta if present (stamped by evaluateDef).
461
+ let varLine: number | undefined
462
+ let varColumn: number | undefined
463
+ let varFile: string | undefined
464
+ const varMetaEntries = resolved.varObj?.meta?.entries ?? []
465
+ for (const [k, v] of varMetaEntries) {
466
+ if (k.kind !== 'keyword') continue
467
+ if (k.name === ':line' && v.kind === 'number') varLine = v.value
468
+ if (k.name === ':column' && v.kind === 'number') varColumn = v.value
469
+ if (k.name === ':file' && v.kind === 'string') varFile = v.value
470
+ }
471
+
400
472
  done(encoder, id, managed.id, {
401
473
  ns: resolved.resolvedNs,
402
474
  name: resolved.localName,
403
475
  doc: meta.doc,
404
476
  'arglists-str': meta.arglistsStr,
405
477
  type: meta.type,
406
- ...(file ? { file } : {}),
478
+ ...((varFile ?? file) ? { file: varFile ?? file } : {}),
479
+ ...(varLine !== undefined ? { line: varLine } : {}),
480
+ ...(varColumn !== undefined ? { column: varColumn } : {}),
407
481
  })
408
482
  }
409
483
 
@@ -435,7 +509,7 @@ function handleEldoc(
435
509
  return
436
510
  }
437
511
 
438
- const meta = extractMeta(resolved.value)
512
+ const meta = extractMeta(resolved)
439
513
  if (!meta.eldocArgs) {
440
514
  done(encoder, id, managed.id, { status: ['no-eldoc', 'done'] })
441
515
  return
@@ -464,7 +538,10 @@ function handleMessage(
464
538
  snapshot: SessionSnapshot,
465
539
  encoder: BEncoderStream,
466
540
  defaultSession: ManagedSession,
467
- sourceRoots?: string[]
541
+ sourceRoots?: string[],
542
+ meshNode?: RemoteEvalNode,
543
+ onOutput?: (text: string) => void,
544
+ importModule?: (specifier: string) => unknown | Promise<unknown>
468
545
  ) {
469
546
  const op = msg['op'] as string
470
547
  const sessionId = msg['session'] as string | undefined
@@ -474,16 +551,32 @@ function handleMessage(
474
551
 
475
552
  switch (op) {
476
553
  case 'clone':
477
- handleClone(msg, sessions, snapshot, encoder, sourceRoots)
554
+ handleClone(msg, sessions, snapshot, encoder, sourceRoots, onOutput, importModule)
478
555
  break
479
556
  case 'describe':
480
557
  handleDescribe(msg, encoder)
481
558
  break
482
559
  case 'eval':
483
- handleEval(msg, managed, encoder)
560
+ void handleEval(msg, managed, encoder, meshNode).catch((e) => {
561
+ const m = e instanceof Error ? e.message : String(e)
562
+ done(encoder, (msg['id'] as string) ?? '', managed.id, {
563
+ ex: m,
564
+ err: m + '\n',
565
+ ns: managed.session.currentNs,
566
+ status: ['eval-error', 'done'],
567
+ })
568
+ })
484
569
  break
485
570
  case 'load-file':
486
- handleLoadFile(msg, managed, encoder)
571
+ void handleLoadFile(msg, managed, encoder).catch((e) => {
572
+ const m = e instanceof Error ? e.message : String(e)
573
+ done(encoder, (msg['id'] as string) ?? '', managed.id, {
574
+ ex: m,
575
+ err: m + '\n',
576
+ ns: managed.session.currentNs,
577
+ status: ['eval-error', 'done'],
578
+ })
579
+ })
487
580
  break
488
581
  case 'complete':
489
582
  handleComplete(msg, managed, encoder)
@@ -513,6 +606,26 @@ export type NreplServerOptions = {
513
606
  port?: number
514
607
  host?: string
515
608
  sourceRoots?: string[]
609
+ /** Pre-created session (with modules already installed). Will be snapshotted internally. */
610
+ session?: Session
611
+ /** Or pass a pre-taken snapshot directly. Takes precedence over `session`. */
612
+ snapshot?: SessionSnapshot
613
+ /** Optional mesh node for routing eval ops when mesh/*eval-target* is set. */
614
+ meshNode?: RemoteEvalNode
615
+ /** Write .nrepl-port to cwd on listen. Default: true. Set false for embedded/server use. */
616
+ writePortFile?: boolean
617
+ /**
618
+ * Called for every stdout chunk from any managed session (i.e. local evals from the editor).
619
+ * Use this to echo output to the server terminal alongside the Calva encoder stream.
620
+ * Example: onOutput: (t) => process.stdout.write(t)
621
+ */
622
+ onOutput?: (text: string) => void
623
+ /**
624
+ * Called when (:require ["specifier" :as Alias]) is encountered in an eval.
625
+ * Forwarded to every managed session cloned from the snapshot.
626
+ * Example: importModule: (s) => import(s)
627
+ */
628
+ importModule?: (specifier: string) => unknown | Promise<unknown>
516
629
  }
517
630
 
518
631
  export function startNreplServer(options: NreplServerOptions = {}): net.Server {
@@ -520,12 +633,19 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
520
633
  const host = options.host ?? '127.0.0.1'
521
634
 
522
635
  // Build a warm snapshot once — all clones skip the core bootstrap.
523
- // Source roots from config discovery are baked in so every cloned session inherits them.
524
- const warmSession = createSession({
525
- sourceRoots: options.sourceRoots,
526
- readFile: (filePath) => readFileSync(filePath, 'utf8'),
527
- })
528
- const snapshot = snapshotSession(warmSession)
636
+ // Callers may provide a pre-created session (with extra modules installed) or snapshot.
637
+ const snapshot: SessionSnapshot =
638
+ options.snapshot ??
639
+ (options.session
640
+ ? snapshotSession(options.session)
641
+ : snapshotSession(
642
+ createSession({
643
+ sourceRoots: options.sourceRoots,
644
+ readFile: (filePath) => readFileSync(filePath, 'utf8'),
645
+ })
646
+ ))
647
+
648
+ const { meshNode, onOutput, importModule } = options
529
649
 
530
650
  const server = net.createServer((socket) => {
531
651
  const encoder = new BEncoderStream()
@@ -538,11 +658,28 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
538
658
 
539
659
  // A default session for session-less messages (e.g. Calva's initial eval)
540
660
  const defaultId = makeSessionId()
541
- const defaultSession = createManagedSession(defaultId, snapshot, encoder, options.sourceRoots)
661
+ const defaultSession = createManagedSession(
662
+ defaultId,
663
+ snapshot,
664
+ encoder,
665
+ options.sourceRoots,
666
+ onOutput,
667
+ importModule
668
+ )
542
669
  sessions.set(defaultId, defaultSession)
543
670
 
544
671
  decoder.on('data', (msg: NreplMessage) => {
545
- handleMessage(msg, sessions, snapshot, encoder, defaultSession, options.sourceRoots)
672
+ handleMessage(
673
+ msg,
674
+ sessions,
675
+ snapshot,
676
+ encoder,
677
+ defaultSession,
678
+ options.sourceRoots,
679
+ meshNode,
680
+ onOutput,
681
+ importModule
682
+ )
546
683
  })
547
684
 
548
685
  socket.on('error', () => {
@@ -555,25 +692,32 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
555
692
  })
556
693
 
557
694
  const portFile = join(process.cwd(), '.nrepl-port')
558
-
559
- server.listen(port, host, () => {
560
- writeFileSync(portFile, String(port), 'utf8')
561
- process.stdout.write(`Conjure nREPL server v${VERSION} started on port ${port}\n`)
562
- })
695
+ const writePortFile = options.writePortFile ?? true
563
696
 
564
697
  const cleanup = () => {
565
698
  if (existsSync(portFile)) unlinkSync(portFile)
566
699
  }
567
- server.on('close', cleanup)
568
- process.on('exit', cleanup)
569
- process.on('SIGINT', () => {
570
- cleanup()
571
- process.exit(0)
572
- })
573
- process.on('SIGTERM', () => {
574
- cleanup()
575
- process.exit(0)
576
- })
700
+
701
+ if (writePortFile) {
702
+ server.listen(port, host, () => {
703
+ writeFileSync(portFile, String(port), 'utf8')
704
+ process.stdout.write(
705
+ `Conjure nREPL server v${VERSION} started on port ${port}\n`
706
+ )
707
+ })
708
+ server.on('close', cleanup)
709
+ process.on('exit', cleanup)
710
+ process.on('SIGINT', () => {
711
+ cleanup()
712
+ process.exit(0)
713
+ })
714
+ process.on('SIGTERM', () => {
715
+ cleanup()
716
+ process.exit(0)
717
+ })
718
+ } else {
719
+ server.listen(port, host)
720
+ }
577
721
 
578
722
  return server
579
723
  }
@@ -1,4 +1,4 @@
1
1
  // This file is auto-generated by scripts/gen-version.mjs
2
2
  // Do not edit manually - changes will be overwritten
3
3
 
4
- export const VERSION = '0.0.11'
4
+ export const VERSION = '0.0.13'