@junejs/server 1.0.0-dev.14 → 1.0.0-dev.17

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.
@@ -208,7 +208,11 @@ export type DoAgentDef = {
208
208
  // channel factories you mount on the worker, plus this DO's env.
209
209
  channels?: (Channel | ChannelFactory)[];
210
210
  env?: unknown;
211
- resources?: Resources;
211
+ // A resolved bag, OR an env-driven — possibly async — provider
212
+ // (bindWorkerResources' shape): opening a D1 handle is async and a DO
213
+ // constructor cannot await, so a provider is resolved lazily at the first
214
+ // turn (once per DO instance) with this def's `env`.
215
+ resources?: Resources | ((env: unknown) => Promise<Resources> | Resources);
212
216
  services?: unknown;
213
217
  // Called on every turn.failed for this session — the app's seam for routing turn
214
218
  // failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
@@ -253,15 +257,25 @@ export class AgentDurableObject {
253
257
  private readonly tools: Tool[];
254
258
  private readonly channels: Channel[];
255
259
  private readonly channelInstructions?: Record<string, string>;
256
- // Built from the DO's env in the constructor (this isolate), shared across turns —
257
- // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
258
- private readonly resources: Resources;
260
+ // A bag is used as-is; a provider (bindWorkerResources' shape) is resolved
261
+ // lazily at the first turn with the def's env — opening a D1 handle is async
262
+ // and this constructor cannot await. Resolved once, shared across turns (env
263
+ // is stable per isolate, like bindWorkerResources memoizes per worker isolate).
264
+ private readonly resourcesInput: DoAgentDef["resources"];
265
+ private resourcesOpened: Promise<Resources> | undefined;
266
+ private readonly doEnv: unknown;
259
267
  private readonly services: unknown;
268
+ private resolveResources(): Promise<Resources> {
269
+ return (this.resourcesOpened ??= Promise.resolve(
270
+ typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
271
+ ));
272
+ }
260
273
  constructor(state: DurableObjectState, def: DoAgentDef) {
261
274
  assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
262
275
  const store = new DoSessionStore(state.storage);
263
276
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
264
- this.resources = def.resources ?? {};
277
+ this.resourcesInput = def.resources;
278
+ this.doEnv = def.env;
265
279
  this.services = def.services;
266
280
  const name = def.name ?? "agent";
267
281
  // Failure observability (#76): a turn that dies after the fast-ACK has no other
@@ -368,8 +382,9 @@ export class AgentDurableObject {
368
382
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
369
383
  async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
370
384
  await ensureScope();
385
+ const resources = await this.resolveResources();
371
386
  const session = this.resolveSession(input.session);
372
- return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
387
+ return runInScope({ resources, services: this.services }, () => session.turn(input));
373
388
  }
374
389
  // Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
375
390
  // it then runs under the DO's own lifetime (a DO stays alive while it has pending
@@ -377,8 +392,9 @@ export class AgentDurableObject {
377
392
  // the default turn-failure log / onTurnError (#76).
378
393
  async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
379
394
  await ensureScope();
395
+ const resources = await this.resolveResources();
380
396
  const session = this.resolveSession(input.session);
381
- return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
397
+ return runInScope({ resources, services: this.services }, () => session.start(input));
382
398
  }
383
399
  // Session reset (#129) for custom shells — the direct sibling of the /reset route. No
384
400
  // scope needed: reset touches only the store (no tools run).
@@ -432,12 +448,14 @@ export class AgentDurableObject {
432
448
  }
433
449
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
434
450
  // db/services (ALS propagates to the .then continuation registered here); subscribing
435
- // happens synchronously right after, before any event can emit.
451
+ // happens synchronously right after, before any event can emit. Resources resolve
452
+ // BEFORE this block — the section from start() to the subscription stays synchronous.
453
+ const resources = await this.resolveResources();
436
454
  let session: AgentSession;
437
455
  let started: { turnId: string };
438
456
  try {
439
457
  session = this.resolveSession(key);
440
- started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
458
+ started = runInScope({ resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
441
459
  } catch (err) {
442
460
  // e.g. the session is suspended awaiting input, or the key mis-matches this
443
461
  // object's identity — a client-resolvable conflict, not a crash
@@ -470,7 +488,7 @@ export class AgentDurableObject {
470
488
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
471
489
  const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
472
490
  Promise.resolve()
473
- .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
491
+ .then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
474
492
  .catch(logRenderFailure)
475
493
  .finally(() => { void it.return?.(); });
476
494
  } catch (err) {
@@ -515,10 +533,13 @@ export class AgentDurableObject {
515
533
  );
516
534
  }
517
535
  }
536
+ // Same discipline as /turn: resolve resources before the synchronous
537
+ // resume-then-subscribe section.
538
+ const resources = await this.resolveResources();
518
539
  let session: AgentSession;
519
540
  try {
520
541
  session = this.resolveSession(key);
521
- runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
542
+ runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
522
543
  } catch (err) {
523
544
  const message = err instanceof Error ? err.message : String(err);
524
545
  // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
@@ -538,7 +559,7 @@ export class AgentDurableObject {
538
559
  const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
539
560
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
540
561
  Promise.resolve()
541
- .then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
562
+ .then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
542
563
  .catch(logRenderFailure)
543
564
  .finally(() => { void it.return?.(); });
544
565
  } catch (err) {
package/src/build.ts CHANGED
@@ -32,6 +32,7 @@ import { localeHref } from "@junejs/core/i18n";
32
32
  import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
33
33
  import { workers, vercel, deno, staticSite, type JuneAdapter, type ResourcePlan } from "./adapter";
34
34
  import type { DocumentConfig } from "@junejs/core/document";
35
+ import { generateAgentModule } from "./agent-compile";
35
36
  import { generateContentModule } from "./content";
36
37
  import { createWorker, type WorkerManifest } from "./worker";
37
38
  import { findMiddlewareFile } from "./router";
@@ -441,6 +442,20 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
441
442
  if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
442
443
  }
443
444
 
445
+ // The durable agent's DO address (createWorker routes the chat endpoint by
446
+ // it). Set whenever an agent/ directory exists — the surface is inert without
447
+ // an env.AGENT binding, so in-process consumers (prerender, the parity test)
448
+ // are unaffected.
449
+ let agentName: string | undefined;
450
+ const agentDir = join(appDir, frozen.agent.runtime.dir);
451
+ if (frozen.agent.runtime.enabled && existsSync(agentDir)) {
452
+ const cfgFile = join(agentDir, "agent.ts");
453
+ const cfg = existsSync(cfgFile)
454
+ ? ((await import(pathToFileURL(cfgFile).href)).default as { name?: string } | undefined)
455
+ : undefined;
456
+ agentName = cfg?.name ?? basename(agentDir);
457
+ }
458
+
444
459
  return {
445
460
  routes,
446
461
  dynamicRoutes,
@@ -450,6 +465,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
450
465
  loadings,
451
466
  document: frozen.document,
452
467
  agent: frozen.agent,
468
+ agentName,
453
469
  i18n: frozen.i18n,
454
470
  earlyHints: frozen.earlyHints,
455
471
  extra,
@@ -521,6 +537,52 @@ export async function juneBuild(
521
537
  config: fullConfig,
522
538
  });
523
539
 
540
+ // ---- durable agent (the F7 mount): compile app/agent → _agent.gen.ts ------
541
+ // The generated entry mounts the compiled module statically (fs discovery
542
+ // can't run on workerd) and exports the per-session Durable Object class.
543
+ // Only a target with Durable Objects can host the session actor — elsewhere
544
+ // the agent surface is skipped with a notice, and an app without an agent/
545
+ // directory emits a byte-identical entry (the parity guarantee).
546
+ const agentDir = join(appDir, frozen.agent.runtime.dir);
547
+ let agentModule: { file: string } | null = null;
548
+ if (frozen.agent.runtime.enabled) {
549
+ const generated = generateAgentModule(agentDir);
550
+ if (generated && adapter.capabilities.durableObjects) {
551
+ agentModule = generated;
552
+ } else if (generated) {
553
+ console.warn(
554
+ `[june build] app/${frozen.agent.runtime.dir}/ found, but the ${adapter.name}() target has no Durable Objects — the durable agent is not mounted.`,
555
+ );
556
+ }
557
+ }
558
+ if (agentModule) {
559
+ // The generated DO's model is Claude, and workerd has no runtime module
560
+ // resolution — the SDK must be a real dependency of the app so the entry's
561
+ // STATIC import bundles it (@junejs/core treats it as an optional peer; the
562
+ // adapter's own lazy import can never be bundled). Fail the build with the
563
+ // fix, never ship a worker whose first model turn dies on a bare import.
564
+ // An explicit node_modules walk, not a resolver API: the check must behave
565
+ // identically on every host (Bun.resolveSync missed a symlinked scoped
566
+ // package on Linux CI that it found on macOS), and Rolldown does its own
567
+ // resolution at bundle time anyway — this is a preflight, not the resolver.
568
+ const sdkResolvable = (fromDir: string): boolean => {
569
+ // Absolute first: with a relative root (juneBuild(".")), dirname(".") is
570
+ // still "." and the walk would stop before reaching a hoisted parent.
571
+ for (let d = resolve(fromDir); ; ) {
572
+ if (existsSync(join(d, "node_modules", "@anthropic-ai", "sdk", "package.json"))) return true;
573
+ const parent = dirname(d);
574
+ if (parent === d) return false;
575
+ d = parent;
576
+ }
577
+ };
578
+ if (!sdkResolvable(appRoot)) {
579
+ throw new Error(
580
+ `app/${frozen.agent.runtime.dir}/ mounts a durable agent whose model is Claude — ` +
581
+ `add the SDK to the app so it bundles for workerd: bun add @anthropic-ai/sdk`,
582
+ );
583
+ }
584
+ }
585
+
524
586
  // Compile the global stylesheet ONCE and content-hash it: the built worker and
525
587
  // prerendered HTML link `/global.<hash>.css`, served immutable (cache forever,
526
588
  // a content change ships a new URL → no revalidation, no stale window). Dev
@@ -691,11 +753,15 @@ export async function juneBuild(
691
753
  } else if (hasResources) {
692
754
  imports.push(`import { bindWorkerResources } from "@junejs/server/resources";`);
693
755
  }
694
- const resourcesField = tursoDb
695
- ? `\n resources: memoizeResources({ db: turso() }),`
756
+ // One expression, TWO instances: the pipeline (createWorker) and the durable
757
+ // agent's DO each open their own provider — the DO is a separate isolate whose
758
+ // env must not be captured by the worker-side memoization.
759
+ const resourcesExpr = tursoDb
760
+ ? `memoizeResources({ db: turso() })`
696
761
  : hasResources
697
- ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
698
- : "";
762
+ ? `bindWorkerResources(${JSON.stringify(resourceFlags)})`
763
+ : null;
764
+ const resourcesField = resourcesExpr ? `\n resources: ${resourcesExpr},` : "";
699
765
 
700
766
  // Opt-in Tier-3 data layer: import its installDataLayer from the declared module
701
767
  // and call it at worker boot — the prod twin of the dev host's dataLayer.install()
@@ -723,6 +789,46 @@ export async function juneBuild(
723
789
  }
724
790
  const servicesField = servicesModule ? `\n services: __appServices,` : "";
725
791
 
792
+ // Durable agent: import the compiled agent module + the DO shell pieces, set
793
+ // agentName on the manifest (what activates createWorker's chat routing), and
794
+ // export the DO class the adapter's wrangler binding names. cloudflare:workers
795
+ // is a workerd runtime module — external to the bundle (see the predicate below).
796
+ if (agentModule) {
797
+ imports.push(`import __agentModule from ${JSON.stringify(importPath(genDir, agentModule.file))};`);
798
+ imports.push(`import { assembleDurable } from "@junejs/core/agent-config";`);
799
+ imports.push(`import { anthropic } from "@junejs/core/agent-models";`);
800
+ imports.push(`import { AgentDurableObject } from "@junejs/server/agent-durable";`);
801
+ imports.push(`import { DurableObject } from "cloudflare:workers";`);
802
+ // STATIC so Rolldown bundles the SDK (the adapter's own import is a
803
+ // non-literal lazy specifier — deliberately unbundleable); injected via
804
+ // anthropic({ client }), which skips that lazy path entirely.
805
+ imports.push(`import Anthropic from "@anthropic-ai/sdk";`);
806
+ }
807
+ const agentNameField = agentModule ? `\n agentName: __agentModule.config.name,` : "";
808
+ const doClass = agentModule
809
+ ? `
810
+ // The durable agent: one Durable Object per session, the compiled agent/
811
+ // directory as its definition — adapted tools (+ read_skill when skills exist),
812
+ // the assembled system prompt, channel factories resolved with the DO's own
813
+ // env, and the app's declared resources opened from THIS isolate's env (lazily,
814
+ // at the first turn — the same ambient db a tool sees in native dev). Model:
815
+ // Anthropic over a statically bundled client (workerd cannot resolve a bare
816
+ // import at runtime), keyed by the ANTHROPIC_API_KEY secret — a missing secret
817
+ // surfaces as the SDK's own clear construction error on the first agent request.
818
+ const __agentDef = assembleDurable(__agentModule);
819
+ export class JuneAgentDO extends DurableObject {
820
+ #agent = new AgentDurableObject(this.ctx, {
821
+ ...__agentDef,
822
+ model: anthropic({ model: __agentModule.config.model, client: new Anthropic({ apiKey: (this.env as { ANTHROPIC_API_KEY?: string }).ANTHROPIC_API_KEY }) }),
823
+ env: this.env,${resourcesExpr ? `\n resources: ${resourcesExpr},` : ""}${servicesModule ? "\n services: __appServices(this.env)," : ""}
824
+ });
825
+ fetch(req: Request): Promise<Response> {
826
+ return this.#agent.fetch(req);
827
+ }
828
+ }
829
+ `
830
+ : "";
831
+
726
832
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
727
833
  ${adapterEntry.imports.join("\n")}
728
834
  ${imports.join("\n")}
@@ -741,12 +847,12 @@ ${chains.join("\n")}
741
847
  ${loadings.join("\n")}
742
848
  },
743
849
  document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
744
- agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
850
+ agent: ${JSON.stringify(frozen.agent)},${agentNameField}${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
745
851
  earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}${servicesField}
746
852
  });
747
853
 
748
854
  ${adapterEntry.wrap("pipeline")}
749
- `;
855
+ ${doClass}`;
750
856
  const entryFile = join(genDir, "worker-entry.tsx");
751
857
  await writeFile(entryFile, entry);
752
858
 
@@ -773,6 +879,8 @@ ${adapterEntry.wrap("pipeline")}
773
879
  external: (id: string) => {
774
880
  // Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
775
881
  if (isBunSpecifier(id)) return true;
882
+ // workerd runtime modules (the durable-agent entry imports cloudflare:workers).
883
+ if (id.startsWith("cloudflare:")) return true;
776
884
  // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
777
885
  if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
778
886
  // Merge: adapter.buildExternal (target-specific, e.g. workers-og for the
@@ -960,16 +1068,23 @@ ${adapterEntry.wrap("pipeline")}
960
1068
  const defaultName = workerName(pkgName ?? basename(appRoot));
961
1069
  // A declared D1-backed db (sqlite/d1) → a D1 binding named DB (the name
962
1070
  // bindWorkerResources reads). turso() connects from env, not a binding — no plan.
963
- const plan: ResourcePlan =
964
- resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
1071
+ const plan: ResourcePlan = {
1072
+ ...(resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
965
1073
  ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
966
- : {};
1074
+ : {}),
1075
+ // The durable agent's DO binding — the name createWorker reads (env.AGENT)
1076
+ // and the class the generated entry exports.
1077
+ ...(agentModule ? { agent: { binding: "AGENT", className: "JuneAgentDO" } } : {}),
1078
+ };
967
1079
  await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles });
968
1080
  if (plan.db) {
969
1081
  console.log(
970
1082
  ` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,
971
1083
  );
972
1084
  }
1085
+ if (plan.agent) {
1086
+ console.log(` ↳ durable agent mounted (${plan.agent.className} → env.${plan.agent.binding}; chat at ${frozen.agent.runtime.chat.path})`);
1087
+ }
973
1088
 
974
1089
  return {
975
1090
  outFile,
package/src/index.ts CHANGED
@@ -91,6 +91,14 @@ export {
91
91
  freezeConfig,
92
92
  type BuildResult,
93
93
  } from "./build";
94
+ export {
95
+ generateAgentModule,
96
+ findAgentDir,
97
+ scanAgentDir,
98
+ emitAgentModule,
99
+ AGENT_MODULE_FILE,
100
+ type AgentDirScan,
101
+ } from "./agent-compile";
94
102
  export { juneDeploy, type DeployResult } from "./deploy";
95
103
  export { migrateD1, wranglerD1, resolveD1Database, type D1Exec } from "./d1-migrate";
96
104
  export { startDevServer, type DevServer, type DevServerOptions } from "./dev";
@@ -9,8 +9,8 @@ import { join } from "node:path";
9
9
  // tsconfig.json is JSONC by convention (comments + trailing commas are idiomatic), and a strict
10
10
  // JSON.parse failure here silently degrades to "not declared" → the conflict warning reappears.
11
11
  // Strip comments (`//` only at line-start/after-whitespace so "https://…" in strings survives)
12
- // and trailing commas before parsing.
13
- const stripJsonc = (s: string): string =>
12
+ // and trailing commas before parsing. Shared: agent-compile's tsconfig sniff parses the same way.
13
+ export const stripJsonc = (s: string): string =>
14
14
  s
15
15
  .replace(/\/\*[\s\S]*?\*\//g, "")
16
16
  .replace(/(^|\s)\/\/.*$/gm, "$1")