conjure-js 0.0.12 → 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 (77) hide show
  1. package/dist-cli/conjure-js.mjs +9360 -5298
  2. package/dist-vite-plugin/index.mjs +9463 -5185
  3. package/package.json +3 -1
  4. package/src/bin/cli.ts +2 -2
  5. package/src/bin/nrepl-symbol.ts +150 -0
  6. package/src/bin/nrepl.ts +289 -167
  7. package/src/bin/version.ts +1 -1
  8. package/src/clojure/core.clj +757 -29
  9. package/src/clojure/core.clj.d.ts +75 -131
  10. package/src/clojure/generated/builtin-namespace-registry.ts +4 -0
  11. package/src/clojure/generated/clojure-core-source.ts +758 -29
  12. package/src/clojure/generated/clojure-set-source.ts +136 -0
  13. package/src/clojure/generated/clojure-walk-source.ts +72 -0
  14. package/src/clojure/set.clj +132 -0
  15. package/src/clojure/set.clj.d.ts +20 -0
  16. package/src/clojure/string.clj.d.ts +14 -0
  17. package/src/clojure/walk.clj +68 -0
  18. package/src/clojure/walk.clj.d.ts +7 -0
  19. package/src/core/assertions.ts +114 -6
  20. package/src/core/bootstrap.ts +337 -0
  21. package/src/core/conversions.ts +48 -31
  22. package/src/core/core-module.ts +303 -0
  23. package/src/core/env.ts +20 -6
  24. package/src/core/evaluator/apply.ts +40 -25
  25. package/src/core/evaluator/arity.ts +8 -8
  26. package/src/core/evaluator/async-evaluator.ts +565 -0
  27. package/src/core/evaluator/collections.ts +28 -5
  28. package/src/core/evaluator/destructure.ts +180 -69
  29. package/src/core/evaluator/dispatch.ts +12 -14
  30. package/src/core/evaluator/evaluate.ts +22 -20
  31. package/src/core/evaluator/expand.ts +45 -15
  32. package/src/core/evaluator/form-parsers.ts +178 -0
  33. package/src/core/evaluator/index.ts +7 -9
  34. package/src/core/evaluator/js-interop.ts +189 -0
  35. package/src/core/evaluator/quasiquote.ts +14 -8
  36. package/src/core/evaluator/recur-check.ts +6 -6
  37. package/src/core/evaluator/special-forms.ts +234 -191
  38. package/src/core/factories.ts +182 -3
  39. package/src/core/index.ts +54 -4
  40. package/src/core/module.ts +136 -0
  41. package/src/core/ns-forms.ts +107 -0
  42. package/src/core/printer.ts +371 -11
  43. package/src/core/reader.ts +84 -33
  44. package/src/core/registry.ts +209 -0
  45. package/src/core/runtime.ts +376 -0
  46. package/src/core/session.ts +253 -487
  47. package/src/core/stdlib/arithmetic.ts +528 -194
  48. package/src/core/stdlib/async-fns.ts +132 -0
  49. package/src/core/stdlib/atoms.ts +291 -56
  50. package/src/core/stdlib/errors.ts +54 -50
  51. package/src/core/stdlib/hof.ts +82 -166
  52. package/src/core/stdlib/js-namespace.ts +344 -0
  53. package/src/core/stdlib/lazy.ts +34 -0
  54. package/src/core/stdlib/maps-sets.ts +322 -0
  55. package/src/core/stdlib/meta.ts +61 -30
  56. package/src/core/stdlib/predicates.ts +325 -187
  57. package/src/core/stdlib/regex.ts +126 -98
  58. package/src/core/stdlib/seq.ts +564 -0
  59. package/src/core/stdlib/strings.ts +164 -135
  60. package/src/core/stdlib/transducers.ts +95 -100
  61. package/src/core/stdlib/utils.ts +292 -130
  62. package/src/core/stdlib/vars.ts +27 -27
  63. package/src/core/stdlib/vectors.ts +122 -0
  64. package/src/core/tokenizer.ts +2 -2
  65. package/src/core/transformations.ts +117 -9
  66. package/src/core/types.ts +98 -2
  67. package/src/host/node-host-module.ts +74 -0
  68. package/src/{vite-plugin-clj/nrepl-relay.ts → nrepl/relay.ts} +72 -11
  69. package/src/vite-plugin-clj/codegen.ts +87 -95
  70. package/src/vite-plugin-clj/index.ts +178 -23
  71. package/src/vite-plugin-clj/namespace-utils.ts +39 -0
  72. package/src/vite-plugin-clj/static-analysis.ts +211 -0
  73. package/src/clojure/demo.clj +0 -72
  74. package/src/clojure/demo.clj.d.ts +0 -0
  75. package/src/core/core-env.ts +0 -61
  76. package/src/core/stdlib/collections.ts +0 -739
  77. 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,11 +164,12 @@ 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
 
@@ -148,13 +178,134 @@ function handleEval(
148
178
  // Calva sends 1-based :line and :column for the start of the evaluated form
149
179
  // in the original file. Convert to 0-based offsets so evaluateDef can add
150
180
  // them to the relative positions the reader computes from the snippet.
151
- const lineOffset = typeof msg['line'] === 'number' ? (msg['line'] as number) - 1 : 0
152
- const colOffset = typeof msg['column'] === 'number' ? (msg['column'] as number) - 1 : 0
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
+ }
153
282
 
154
283
  try {
155
- const result = managed.session.evaluate(code, { lineOffset, colOffset })
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
+ )
156
307
  done(encoder, id, managed.id, {
157
- value: printString(result),
308
+ value: resultStr,
158
309
  ns: managed.session.currentNs,
159
310
  })
160
311
  } catch (error) {
@@ -168,11 +319,11 @@ function handleEval(
168
319
  }
169
320
  }
170
321
 
171
- function handleLoadFile(
322
+ async function handleLoadFile(
172
323
  msg: NreplMessage,
173
324
  managed: ManagedSession,
174
325
  encoder: BEncoderStream
175
- ) {
326
+ ): Promise<void> {
176
327
  const id = (msg['id'] as string) ?? ''
177
328
  const source = (msg['file'] as string) ?? ''
178
329
  const fileName = (msg['file-name'] as string) ?? ''
@@ -193,7 +344,14 @@ function handleLoadFile(
193
344
 
194
345
  const nsHint =
195
346
  fileName.replace(/\.clj$/, '').replace(/\//g, '.') || undefined
196
- const loadedNs = managed.session.loadFile(source, nsHint, filePath || undefined)
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
+ )
197
355
 
198
356
  // Track the file path for this namespace so info/lookup can return :file
199
357
  // for go-to-definition support.
@@ -254,120 +412,15 @@ function resolveSymbol(
254
412
  sym: string,
255
413
  managed: ManagedSession,
256
414
  contextNs?: string
257
- ): { value: CljValue; resolvedNs: string; localName: string } | null {
258
- const ns = contextNs ?? managed.session.currentNs
259
- const slashIdx = sym.indexOf('/')
260
-
261
- if (slashIdx > 0) {
262
- const qualifier = sym.slice(0, slashIdx)
263
- const localName = sym.slice(slashIdx + 1)
264
-
265
- // 1. Try as full namespace name (use registry Env for chain-based lookup)
266
- const nsEnvFull = managed.session.registry.get(qualifier)
267
- if (nsEnvFull) {
268
- const value = tryLookup(localName, nsEnvFull)
269
- if (value !== undefined) return { value, resolvedNs: qualifier, localName }
270
- }
271
-
272
- // 2. Try as alias (:as str → clojure.string)
273
- const currentNsData = managed.session.getNs(ns)
274
- const aliasedNs = currentNsData?.aliases.get(qualifier)
275
- if (aliasedNs) {
276
- const v = aliasedNs.vars.get(localName)
277
- if (v !== undefined)
278
- return { value: v.value, resolvedNs: aliasedNs.name, localName }
279
- }
280
-
281
- return null
282
- }
283
-
284
- // Unqualified symbol
285
- const localName = sym
286
- const nsEnvFull = managed.session.registry.get(ns)
287
- if (!nsEnvFull) return null
288
- const value = tryLookup(sym, nsEnvFull)
289
- if (value === undefined) return null
290
-
291
- // Determine the namespace where this symbol is defined.
292
- // CljVar carries the authoritative ns; fall back to other heuristics.
293
- const varObj = lookupVar(sym, nsEnvFull)
294
- let resolvedNs: string
295
- if (varObj) {
296
- resolvedNs = varObj.ns
297
- } else if (value.kind === 'function' || value.kind === 'macro') {
298
- resolvedNs = getNamespaceEnv(value.env).ns?.name ?? ns
299
- } else if (value.kind === 'native-function') {
300
- const i = value.name.indexOf('/')
301
- resolvedNs = i > 0 ? value.name.slice(0, i) : ns
302
- } else {
303
- resolvedNs = ns
304
- }
305
-
306
- return { value, resolvedNs, localName }
415
+ ) {
416
+ return resolveSymbolShared(sym, managed.session, contextNs)
307
417
  }
308
418
 
309
- function extractMeta(value: CljValue): {
310
- doc: string
311
- arglistsStr: string
312
- eldocArgs: string[][] | null
313
- type: string
314
- } {
315
- const type =
316
- value.kind === 'macro'
317
- ? 'macro'
318
- : value.kind === 'function' || value.kind === 'native-function'
319
- ? 'function'
320
- : 'var'
321
-
322
- const meta: CljMap | undefined =
323
- value.kind === 'function'
324
- ? value.meta
325
- : value.kind === 'native-function'
326
- ? value.meta
327
- : undefined
328
-
329
- let doc = ''
330
- let arglistsStr = ''
331
- let eldocArgs: string[][] | null = null
332
-
333
- if (meta) {
334
- const docEntry = meta.entries.find(
335
- ([k]) => k.kind === 'keyword' && k.name === ':doc'
336
- )
337
- if (docEntry && docEntry[1].kind === 'string') doc = docEntry[1].value
338
-
339
- const argsEntry = meta.entries.find(
340
- ([k]) => k.kind === 'keyword' && k.name === ':arglists'
341
- )
342
- if (argsEntry && argsEntry[1].kind === 'vector') {
343
- const arglists = argsEntry[1]
344
- arglistsStr = '(' + arglists.value.map((al) => printString(al)).join(' ') + ')'
345
- eldocArgs = arglists.value.map((al) => {
346
- if (al.kind !== 'vector') return [printString(al)]
347
- return al.value.map((p) => (p.kind === 'symbol' ? p.name : printString(p)))
348
- })
349
- }
350
- }
351
-
352
- // Fallback: derive arglists from structural arities (fn/macro without meta)
353
- if (
354
- arglistsStr === '' &&
355
- (value.kind === 'function' || value.kind === 'macro')
356
- ) {
357
- const arityStrs = 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.join(' ') + ']'
361
- })
362
- arglistsStr = '(' + arityStrs.join(' ') + ')'
363
- eldocArgs = value.arities.map((arity) => {
364
- const params = arity.params.map((p) => printString(p))
365
- if (arity.restParam) params.push('&', printString(arity.restParam))
366
- return params
367
- })
368
- }
369
-
370
- 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)
371
424
  }
372
425
 
373
426
  function handleInfo(
@@ -401,20 +454,19 @@ function handleInfo(
401
454
  return
402
455
  }
403
456
 
404
- const meta = extractMeta(resolved.value)
457
+ const meta = extractMeta(resolved)
405
458
  const file = managed.nsToFile.get(resolved.resolvedNs)
406
459
 
407
460
  // Extract :line/:column/:file from var meta if present (stamped by evaluateDef).
408
461
  let varLine: number | undefined
409
462
  let varColumn: number | undefined
410
463
  let varFile: string | undefined
411
- if (resolved.value.kind === 'var' && resolved.value.meta) {
412
- for (const [k, v] of resolved.value.meta.entries) {
413
- if (k.kind !== 'keyword') continue
414
- if (k.name === ':line' && v.kind === 'number') varLine = v.value
415
- if (k.name === ':column' && v.kind === 'number') varColumn = v.value
416
- if (k.name === ':file' && v.kind === 'string') varFile = v.value
417
- }
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
418
470
  }
419
471
 
420
472
  done(encoder, id, managed.id, {
@@ -423,8 +475,8 @@ function handleInfo(
423
475
  doc: meta.doc,
424
476
  'arglists-str': meta.arglistsStr,
425
477
  type: meta.type,
426
- ...(varFile ?? file ? { file: varFile ?? file } : {}),
427
- ...(varLine !== undefined ? { line: varLine } : {}),
478
+ ...((varFile ?? file) ? { file: varFile ?? file } : {}),
479
+ ...(varLine !== undefined ? { line: varLine } : {}),
428
480
  ...(varColumn !== undefined ? { column: varColumn } : {}),
429
481
  })
430
482
  }
@@ -457,7 +509,7 @@ function handleEldoc(
457
509
  return
458
510
  }
459
511
 
460
- const meta = extractMeta(resolved.value)
512
+ const meta = extractMeta(resolved)
461
513
  if (!meta.eldocArgs) {
462
514
  done(encoder, id, managed.id, { status: ['no-eldoc', 'done'] })
463
515
  return
@@ -486,7 +538,10 @@ function handleMessage(
486
538
  snapshot: SessionSnapshot,
487
539
  encoder: BEncoderStream,
488
540
  defaultSession: ManagedSession,
489
- sourceRoots?: string[]
541
+ sourceRoots?: string[],
542
+ meshNode?: RemoteEvalNode,
543
+ onOutput?: (text: string) => void,
544
+ importModule?: (specifier: string) => unknown | Promise<unknown>
490
545
  ) {
491
546
  const op = msg['op'] as string
492
547
  const sessionId = msg['session'] as string | undefined
@@ -496,16 +551,32 @@ function handleMessage(
496
551
 
497
552
  switch (op) {
498
553
  case 'clone':
499
- handleClone(msg, sessions, snapshot, encoder, sourceRoots)
554
+ handleClone(msg, sessions, snapshot, encoder, sourceRoots, onOutput, importModule)
500
555
  break
501
556
  case 'describe':
502
557
  handleDescribe(msg, encoder)
503
558
  break
504
559
  case 'eval':
505
- 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
+ })
506
569
  break
507
570
  case 'load-file':
508
- 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
+ })
509
580
  break
510
581
  case 'complete':
511
582
  handleComplete(msg, managed, encoder)
@@ -535,6 +606,26 @@ export type NreplServerOptions = {
535
606
  port?: number
536
607
  host?: string
537
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>
538
629
  }
539
630
 
540
631
  export function startNreplServer(options: NreplServerOptions = {}): net.Server {
@@ -542,12 +633,19 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
542
633
  const host = options.host ?? '127.0.0.1'
543
634
 
544
635
  // Build a warm snapshot once — all clones skip the core bootstrap.
545
- // Source roots from config discovery are baked in so every cloned session inherits them.
546
- const warmSession = createSession({
547
- sourceRoots: options.sourceRoots,
548
- readFile: (filePath) => readFileSync(filePath, 'utf8'),
549
- })
550
- 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
551
649
 
552
650
  const server = net.createServer((socket) => {
553
651
  const encoder = new BEncoderStream()
@@ -560,11 +658,28 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
560
658
 
561
659
  // A default session for session-less messages (e.g. Calva's initial eval)
562
660
  const defaultId = makeSessionId()
563
- 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
+ )
564
669
  sessions.set(defaultId, defaultSession)
565
670
 
566
671
  decoder.on('data', (msg: NreplMessage) => {
567
- 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
+ )
568
683
  })
569
684
 
570
685
  socket.on('error', () => {
@@ -577,25 +692,32 @@ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
577
692
  })
578
693
 
579
694
  const portFile = join(process.cwd(), '.nrepl-port')
580
-
581
- server.listen(port, host, () => {
582
- writeFileSync(portFile, String(port), 'utf8')
583
- process.stdout.write(`Conjure nREPL server v${VERSION} started on port ${port}\n`)
584
- })
695
+ const writePortFile = options.writePortFile ?? true
585
696
 
586
697
  const cleanup = () => {
587
698
  if (existsSync(portFile)) unlinkSync(portFile)
588
699
  }
589
- server.on('close', cleanup)
590
- process.on('exit', cleanup)
591
- process.on('SIGINT', () => {
592
- cleanup()
593
- process.exit(0)
594
- })
595
- process.on('SIGTERM', () => {
596
- cleanup()
597
- process.exit(0)
598
- })
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
+ }
599
721
 
600
722
  return server
601
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.12'
4
+ export const VERSION = '0.0.13'