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

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.
@@ -0,0 +1,101 @@
1
+ // connection-auth.ts — turn an authenticated principal into a per-call bearer
2
+ // token for an outbound connection (provider / mcp / openapi).
3
+ //
4
+ // A connection's `auth(ctx)` seam resolves the credential PER CALL, server-side,
5
+ // so the token never reaches the model. The common, fiddly case is "use the
6
+ // CALLER's linked OAuth account" — look up the principal's stored token for a
7
+ // provider, fail closed when it isn't there. This module standardizes that:
8
+ //
9
+ // • linkedAccountAuth — the generic core. You inject a `store` (where account
10
+ // tokens live); it handles the ctx→userId extraction and the fail-closed
11
+ // contract. Reusable across connections whose `auth` is resolved PER CALL
12
+ // with the caller's identity — i.e. PROVIDER connections (Google Drive), and
13
+ // MCP/OpenAPI remotes that don't authenticate discovery.
14
+ //
15
+ // ⚠️ Not for MCP/OpenAPI connections that DO require auth for discovery:
16
+ // connectMcp/connectOpenapi call `auth(undefined)` at initialize/tools-list/
17
+ // schema-fetch time (before any turn), and this helper fails closed on a
18
+ // missing principal — so discovery would report zero tools. Those need a
19
+ // discovery-scoped credential, not a per-caller one.
20
+ // • betterAuthAccountTokenStore / betterAuthAccessToken — the blessed Better
21
+ // Auth convenience. Kept STRUCTURAL (no `better-auth` import), so wiring it
22
+ // adds no dependency and stays fully overridable — swap the store for a
23
+ // custom table, a secrets vault, or a Service Account minter.
24
+ //
25
+ // Pure logic (no node:*), tied only to the identity types in @junejs/core — the
26
+ // auth INTEGRATION lives in the host layer by design (see docs/auth-integration.md),
27
+ // which is why it is here and not in the pure contract layer.
28
+
29
+ import type { ActionContext } from "@junejs/core/context";
30
+
31
+ // Where a principal's OAuth account tokens live. Given the caller's principal id
32
+ // and a provider, return a FRESH access token (the store refreshes if needed),
33
+ // or null when the user hasn't linked that provider. Better Auth's account table
34
+ // is the blessed implementation; anything satisfying this shape works.
35
+ export type AccountTokenStore = (args: {
36
+ userId: string;
37
+ providerId: string;
38
+ }) => Promise<{ accessToken: string } | null> | { accessToken: string } | null;
39
+
40
+ export type LinkedAccountAuthOptions = {
41
+ // Which linked provider to mint a token for, e.g. "google".
42
+ providerId: string;
43
+ // The token source (injected — this is the overridable seam).
44
+ store: AccountTokenStore;
45
+ };
46
+
47
+ // The connection `auth(ctx)` shape: resolve a bearer token from the call's
48
+ // identity, server-side. (Structurally identical to connections' internal Auth
49
+ // and to GoogleDriveAuth.)
50
+ export type ConnectionAuth = (ctx?: ActionContext) => Promise<{ token: string }>;
51
+
52
+ // Build an `auth(ctx)` for ANY June connection that mints the CALLER's
53
+ // linked-account token. FAIL CLOSED: a missing principal or an unlinked account
54
+ // throws — so an authenticated-but-unlinked user gets a clear error, and
55
+ // (together with the connection's `requiresPrincipal`, which hides the tools from
56
+ // anonymous turns) the capability is never reachable without a real credential.
57
+ export function linkedAccountAuth(opts: LinkedAccountAuthOptions): ConnectionAuth {
58
+ return async (ctx?: ActionContext): Promise<{ token: string }> => {
59
+ const userId = ctx?.user?.id;
60
+ if (!userId) {
61
+ throw new Error(`connection auth (${opts.providerId}): no authenticated principal — set requiresPrincipal and resolve identity on the surface`);
62
+ }
63
+ const record = await opts.store({ userId, providerId: opts.providerId });
64
+ if (!record?.accessToken) {
65
+ throw new Error(`connection auth (${opts.providerId}): user "${userId}" has no linked "${opts.providerId}" account`);
66
+ }
67
+ return { token: record.accessToken };
68
+ };
69
+ }
70
+
71
+ // The minimal Better-Auth surface this reads — STRUCTURAL, so importing the
72
+ // helper doesn't drag in `better-auth`. A real Better Auth server instance
73
+ // satisfies it: `auth.api.getAccessToken` takes an ENDPOINT INPUT shaped as
74
+ // `{ body: { providerId, userId, accountId? }, headers? }` and refreshes the
75
+ // stored token when expired. The reply key differs across versions (accessToken
76
+ // vs token), so both are accepted.
77
+ export type BetterAuthLike = {
78
+ api: {
79
+ getAccessToken: (input: {
80
+ body: { providerId: string; userId?: string; accountId?: string };
81
+ headers?: HeadersInit;
82
+ }) => Promise<{ accessToken?: string; token?: string } | null | undefined>;
83
+ };
84
+ };
85
+
86
+ // Adapt a Better-Auth-shaped instance into an AccountTokenStore. Passes `userId`
87
+ // in the endpoint `body` (server-side lookup — no request session needed).
88
+ export function betterAuthAccountTokenStore(auth: BetterAuthLike): AccountTokenStore {
89
+ return async ({ userId, providerId }) => {
90
+ const res = await auth.api.getAccessToken({ body: { providerId, userId } });
91
+ const accessToken = res?.accessToken ?? res?.token;
92
+ return accessToken ? { accessToken } : null;
93
+ };
94
+ }
95
+
96
+ // Sugar: a fail-closed `auth(ctx)` backed by Better Auth in one call.
97
+ //
98
+ // googleDriveConnection({ requiresPrincipal: true, auth: betterAuthAccessToken(auth, { providerId: "google" }) })
99
+ export function betterAuthAccessToken(auth: BetterAuthLike, opts: { providerId: string }): ConnectionAuth {
100
+ return linkedAccountAuth({ providerId: opts.providerId, store: betterAuthAccountTokenStore(auth) });
101
+ }
@@ -0,0 +1,151 @@
1
+ // The content FREEZE: content/<collection>/*.md → app/_content.ts, the
2
+ // build-time content manifest. Extracted from build.ts — this is the whole
3
+ // "remove node:fs from the worker graph" step, including the self-healing
4
+ // bootstrap for wrapper-generated configs that import app/_content.ts.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ import type { ContentSource, JuneConfig } from "@junejs/core/config";
13
+ import { findJuneConfigPath, loadJuneConfig } from "./config-loader";
14
+ import { generateContentModule } from "./content";
15
+
16
+ // content/<collection>/*.md → app/_content.ts (the build-time content manifest).
17
+ // This is the FREEZE that removes node:fs from the worker graph: routes import
18
+ // frozen entries from ./_content instead of reading the filesystem at request.
19
+ //
20
+ // Extra sources (config `content.sources`) merge dirs OUTSIDE `content/` into named
21
+ // collections — the docs-as-code seam (a repo's own `docs/` feeds the site directly).
22
+ // Their dirs resolve against the app root, so "../docs" reaches a sibling directory.
23
+ //
24
+ // Locale buckets are DECLARED, not guessed: only dirs named in config i18n
25
+ // (defaultLocale + locales keys) split off as locale mirrors. Guessing by shape
26
+ // (the old BCP-47 regex) silently swallowed any 2–3-letter folder — `cli/`,
27
+ // `sdk/`, `api/`, `faq/` all read as locales and vanished from the default set.
28
+ // No i18n config ⇒ NO buckets (an undeclared locale is not a locale). The regex
29
+ // remains only as the fallback when the config itself cannot be loaded.
30
+ export async function generateContent(appRoot: string): Promise<string[]> {
31
+ const contentDir = join(appRoot, "content");
32
+ // app/ is the output dir for _content.ts (and the seed). Ensure it exists so the now-always
33
+ // write can't throw an opaque ENOENT when called standalone (e.g. `june gen`); the full build's
34
+ // own "no app/ directory" check runs earlier, so this never masks that clearer error.
35
+ await mkdir(join(appRoot, "app"), { recursive: true });
36
+ const emit = async (sources: ContentSource[], knownLocales: readonly string[] | undefined): Promise<string[]> => {
37
+ // Emission (incl. the per-locale layout and source merging) lives in ./content so it's
38
+ // pure and unit-testable; this stays the thin fs wrapper.
39
+ const { code, names } = generateContentModule(contentDir, knownLocales, sources);
40
+ // Always write, even with zero collections: `code` still carries the canonical ContentEntry
41
+ // type, which is the single source of truth the bootstrap seed appends its stubs against (an
42
+ // app with no local content/ — docs-as-code — needs this valid empty module to exist).
43
+ await writeFile(join(appRoot, "app", "_content.ts"), code);
44
+ return names;
45
+ };
46
+ const resolveSources = (sources: ContentSource[]): ContentSource[] =>
47
+ sources.map((s) => ({ ...s, dir: resolve(appRoot, s.dir) }));
48
+ const declaredLocales = (config: JuneConfig): string[] =>
49
+ config.i18n ? [...new Set([config.i18n.defaultLocale, ...Object.keys(config.i18n.locales)])] : [];
50
+ // Learn sources + locales from june.config.ts — TOLERANTLY. A wrapper-generated config (e.g.
51
+ // Kura's) imports app/_content.ts, which does not exist before the FIRST freeze. So on a failed
52
+ // config load: generate a default scan to create that import's target, then re-probe the config
53
+ // and regenerate with the real sources/locales. Self-healing, no bootstrap flag. Content errors
54
+ // from emit() (slug collision, missing source dir) stay loud — only the CONFIG LOAD is tolerated.
55
+ let config: JuneConfig | null = null;
56
+ try {
57
+ config = await loadJuneConfig(appRoot);
58
+ } catch {
59
+ /* two-pass below */
60
+ }
61
+ if (config) return emit(resolveSources(config.content?.sources ?? []), declaredLocales(config));
62
+ // Pass 1 (throwaway when the probe succeeds): legacy regex locale detection, since the
63
+ // declared set is unknowable without the config. emit() writes app/_content.ts with the
64
+ // canonical ContentEntry type plus a `const` per collection FOUND — but a docs-as-code app
65
+ // keeps ALL content in external content.sources with NO local content/, so it finds zero
66
+ // collections and thus emits no EXPORTS (no `DOCS`). seedContentImports then appends stubs for
67
+ // the exact names the config imports (e.g. Kura's `import { DOCS }`), so the re-probe can load.
68
+ const names = await emit([], undefined);
69
+ await seedContentImports(appRoot);
70
+ const probed = probeConfigFresh(appRoot);
71
+ if (probed === null) {
72
+ console.warn("[june gen] june.config.ts failed to load — content.sources/i18n locales (if any) not applied");
73
+ return names;
74
+ }
75
+ return emit(resolveSources(probed.sources), probed.locales);
76
+ }
77
+
78
+ // Bootstrap seed: ensure app/_content.ts exports every name the config imports from it, so the
79
+ // re-probe's config load resolves BEFORE the first real freeze. A docs-as-code app (content only
80
+ // in external content.sources, no local content/) leaves Pass 1's default scan empty — nothing
81
+ // seeds the collections — so here we append stubs for the EXACT named imports the config takes
82
+ // from the app/_content module (a bare `import { DOCS }` of it → `export const DOCS = []`). The
83
+ // caller's emit() already wrote the module's canonical ContentEntry type (even when empty), so the
84
+ // stubs type against that single source of truth. Overwritten by the real freeze that follows a
85
+ // successful probe; a no-op once content exists.
86
+ async function seedContentImports(appRoot: string): Promise<void> {
87
+ const cfgPath = findJuneConfigPath(appRoot);
88
+ if (!cfgPath) return;
89
+ const contentFile = join(appRoot, "app", "_content.ts");
90
+ const current = existsSync(contentFile) ? await readFile(contentFile, "utf8") : "";
91
+ const cfgSrc = await readFile(cfgPath, "utf8");
92
+ // Only stub valid JS identifiers — a name carrying comments/other tokens (rare, but valid TS)
93
+ // must never reach `new RegExp(...)` (would throw) or the emitted stub (would be invalid TS).
94
+ const isIdent = (s: string): boolean => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
95
+ // A Set so a name matched more than once (e.g. a comment mentioning the import + the real one)
96
+ // yields ONE stub — a duplicate `export const <name>` would be invalid TS. Also skip names the
97
+ // module already exports.
98
+ const seen = new Set<string>();
99
+ const stubs: string[] = [];
100
+ // Each named `import { A, B as C }` the config takes from the app/_content module → stub each.
101
+ // The `(?:\.\w+)?` tolerates an explicit extension (`app/_content.ts`/`.js`/`.mjs`), which
102
+ // NodeNext / verbatimModuleSyntax configs require — else the import goes undetected and unstubbed.
103
+ for (const m of cfgSrc.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*app\/_content(?:\.\w+)?["']/g)) {
104
+ // Strip block + line comments from the specifier list BEFORE splitting: a valid
105
+ // `{ DOCS /* keep */ }` must still yield the identifier `DOCS` (not be dropped), and a comment
106
+ // could otherwise carry a `,` that breaks the split. Then drop `type` markers per specifier.
107
+ const inner = (m[1] ?? "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
108
+ for (const part of inner.split(",")) {
109
+ const name = part.replace(/\btype\b/g, "").split(/\s+as\s+/).pop()?.trim();
110
+ if (!name || !isIdent(name) || seen.has(name)) continue;
111
+ seen.add(name);
112
+ // Escape before interpolating: a valid identifier may contain `$`, a regex metachar (an
113
+ // anchor), which would corrupt the already-exported check. The stub below uses the raw name.
114
+ const reName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ if (!new RegExp(`export (?:const|function|type) ${reName}\\b`).test(current)) {
116
+ stubs.push(`export const ${name}: ContentEntry[] = [];`);
117
+ }
118
+ }
119
+ }
120
+ if (stubs.length) await writeFile(contentFile, current + stubs.join("\n") + "\n");
121
+ }
122
+
123
+ // Re-probe ONLY the content-relevant config (sources + declared i18n locales) in a FRESH
124
+ // subprocess. The bootstrap retry can't re-import in-process: a failed ESM load is cached as
125
+ // errored, and Bun also caches the failed RESOLUTION of the config's own imports — so a
126
+ // same-process retry (even cache-busted) re-rejects after the missing app/_content.ts appears.
127
+ // A child process has a clean module map, and both fields are plain JSON, so they survive the
128
+ // pipe. Returns null when the config is genuinely broken.
129
+ function probeConfigFresh(appRoot: string): { sources: ContentSource[]; locales: string[] } | null {
130
+ const path = findJuneConfigPath(appRoot);
131
+ if (!path) return { sources: [], locales: [] };
132
+ // Markers isolate the JSON from anything the config prints at import time. `.then` (not TLA)
133
+ // so the eval works as CJS under both `bun -e` and `node -e`; execArgv carries loader flags
134
+ // (e.g. --experimental-strip-types) so the child can read the same TS config the parent does.
135
+ const code =
136
+ `import(${JSON.stringify(pathToFileURL(path).href)}).then(` +
137
+ `(m) => { const c = m.default ?? {}; process.stdout.write("\\n__JUNE_CONTENT__" + JSON.stringify({ ` +
138
+ `sources: c.content?.sources ?? [], ` +
139
+ `locales: c.i18n ? [...new Set([c.i18n.defaultLocale, ...Object.keys(c.i18n.locales ?? {})])] : [] ` +
140
+ `}) + "__END__"); }, () => process.exit(42));`;
141
+ try {
142
+ const out = execFileSync(process.execPath, [...process.execArgv, "-e", code], {
143
+ encoding: "utf8",
144
+ stdio: ["ignore", "pipe", "ignore"],
145
+ });
146
+ const m = out.match(/__JUNE_CONTENT__(.*?)__END__/s);
147
+ return m ? (JSON.parse(m[1]!) as { sources: ContentSource[]; locales: string[] }) : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
package/src/index.ts CHANGED
@@ -81,6 +81,20 @@ export {
81
81
  type Resolved,
82
82
  type LayoutComponent,
83
83
  } from "./pipeline";
84
+ // Connection auth helpers — turn an authenticated principal into a per-call
85
+ // bearer token for an outbound connection (provider/mcp/openapi). The auth
86
+ // INTEGRATION lives in the host layer by design, so these sit here (not in pure
87
+ // @junejs/core). Generic + overridable; the Better Auth variant is structural
88
+ // (adds no dependency).
89
+ export {
90
+ linkedAccountAuth,
91
+ betterAuthAccessToken,
92
+ betterAuthAccountTokenStore,
93
+ type AccountTokenStore,
94
+ type LinkedAccountAuthOptions,
95
+ type ConnectionAuth,
96
+ type BetterAuthLike,
97
+ } from "./connection-auth";
84
98
  export { createApp, type JuneApp, type CreateAppOptions } from "./app";
85
99
  export { createWorker, type WorkerManifest } from "./worker";
86
100
  export {
@@ -0,0 +1,128 @@
1
+ // The FREEZE, in-process: import route modules + layouts, build the manifest a
2
+ // createWorker() can run immediately. Extracted from build.ts; used by
3
+ // prerender and by the parity test (its render path is identical to the
4
+ // Rolldown-bundled worker).
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
11
+ import { freezeConfig } from "./config-freeze";
12
+ import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
13
+ import { scanAppRoutes } from "./route-scan";
14
+ import { findMiddlewareFile } from "./router";
15
+ import { resolveBoundary } from "./segment";
16
+ import type { WorkerManifest } from "./worker";
17
+
18
+ export type ImportedLayout = { component: LayoutComponent; boundary: boolean };
19
+ export async function importLayout(file: string): Promise<ImportedLayout | null> {
20
+ const mod = (await import(pathToFileURL(file).href)) as {
21
+ default?: LayoutComponent;
22
+ segmentBoundary?: unknown;
23
+ };
24
+ return typeof mod.default === "function"
25
+ ? { component: mod.default, boundary: mod.segmentBoundary === true }
26
+ : null;
27
+ }
28
+
29
+ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
30
+ const appDir = join(appRoot, "app");
31
+ const frozen = await freezeConfig(appRoot);
32
+ const scanned = await scanAppRoutes(appRoot);
33
+
34
+ const layoutCache = new Map<string, ImportedLayout | null>();
35
+ const loadCached = async (f: string): Promise<ImportedLayout | null> => {
36
+ if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
37
+ return layoutCache.get(f) ?? null;
38
+ };
39
+ // The chain (root→leaf) + boundary index + shell key, via the SHARED resolver
40
+ // (the one place the deepest-wins rule lives), so the frozen manifest and the
41
+ // dev resolver can't drift — the parity contract.
42
+ const componentsFor = async (
43
+ files: string[],
44
+ ): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; key: string | null }> => {
45
+ const items = [];
46
+ for (const f of files) {
47
+ const c = await loadCached(f);
48
+ items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
49
+ }
50
+ return resolveBoundary(items);
51
+ };
52
+
53
+ const loadingCache = new Map<string, LoadingComponent | null>();
54
+ const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
55
+ if (!file) return undefined;
56
+ if (!loadingCache.has(file)) {
57
+ const loaded = await importLayout(file);
58
+ loadingCache.set(file, loaded ? (loaded.component as LoadingComponent) : null);
59
+ }
60
+ return loadingCache.get(file) ?? undefined;
61
+ };
62
+
63
+ const routes: Record<string, BrandedRoute> = {};
64
+ const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
65
+ const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
66
+ const layoutChains: Record<string, LayoutComponent[]> = {};
67
+ const layoutBoundaries: Record<string, { index: number; key: string }> = {};
68
+ const loadings: Record<string, LoadingComponent> = {};
69
+
70
+ for (const r of scanned) {
71
+ const mod = await import(pathToFileURL(r.file).href);
72
+ // Resource route (route.*): the default export is the Response handler.
73
+ if (r.resource) {
74
+ const handler = (mod as { default?: unknown }).default;
75
+ if (typeof handler === "function") resourceRoutes.push({ pattern: r.path, handler: handler as ResourceHandler });
76
+ continue;
77
+ }
78
+ const def = routeFromModule(mod);
79
+ if (!def) continue;
80
+ const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
81
+ const loading = await loadingFor(r.loading);
82
+ if (r.dynamic) {
83
+ dynamicRoutes.push({ pattern: r.path, def });
84
+ layoutChains[r.path] = chain;
85
+ } else {
86
+ routes[r.path] = def;
87
+ layoutChains[r.path] = chain;
88
+ }
89
+ if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
90
+ if (loading) loadings[r.path] = loading;
91
+ }
92
+
93
+ let extra: ExtraHandler | undefined;
94
+ const extraFile = findMiddlewareFile(appDir);
95
+ if (extraFile) {
96
+ const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
97
+ if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
98
+ }
99
+
100
+ // The durable agent's DO address (createWorker routes the chat endpoint by
101
+ // it). Set whenever an agent/ directory exists — the surface is inert without
102
+ // an env.AGENT binding, so in-process consumers (prerender, the parity test)
103
+ // are unaffected.
104
+ let agentName: string | undefined;
105
+ const agentDir = join(appDir, frozen.agent.runtime.dir);
106
+ if (frozen.agent.runtime.enabled && existsSync(agentDir)) {
107
+ const cfgFile = join(agentDir, "agent.ts");
108
+ const cfg = existsSync(cfgFile)
109
+ ? ((await import(pathToFileURL(cfgFile).href)).default as { name?: string } | undefined)
110
+ : undefined;
111
+ agentName = cfg?.name ?? basename(agentDir);
112
+ }
113
+
114
+ return {
115
+ routes,
116
+ dynamicRoutes,
117
+ resourceRoutes,
118
+ layoutChains,
119
+ layoutBoundaries,
120
+ loadings,
121
+ document: frozen.document,
122
+ agent: frozen.agent,
123
+ agentName,
124
+ i18n: frozen.i18n,
125
+ earlyHints: frozen.earlyHints,
126
+ extra,
127
+ };
128
+ }
@@ -0,0 +1,84 @@
1
+ // Filesystem route discovery for the build: walk app/ for page.* files and
2
+ // merge in the framework's .june/routes/ slot. Extracted from build.ts so the
3
+ // scan/merge rules live beside each other, in one place — buildManifest and
4
+ // juneBuild consume the SAME merged list via scanAppRoutes (they previously
5
+ // each inlined the merge).
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { readdir } from "node:fs/promises";
9
+ import { basename, join, relative, sep } from "node:path";
10
+
11
+ // The segment layout CHAIN root→leaf: every directory level (route groups
12
+ // included) may contribute a layout.* that wraps routes below it.
13
+ export type RouteEntry = {
14
+ path: string;
15
+ file: string;
16
+ dynamic: boolean;
17
+ resource?: boolean; // a route.* resource route (raw-Response handler), not a page
18
+ layouts: string[];
19
+ loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
20
+ };
21
+
22
+ const PAGE_BASENAMES = new Set(["page", "index"]);
23
+ const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
24
+
25
+ const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
26
+
27
+ function segmentFile(dir: string, base: string): string | undefined {
28
+ return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
29
+ }
30
+
31
+ // Walk app/ for page.* files → route paths (mirrors router.ts conventions:
32
+ // route groups vanish from URLs, `_`-prefixed entries are private), carrying the
33
+ // layout chain accumulated from each directory level.
34
+ export async function scanRoutes(
35
+ appDir: string,
36
+ dir = appDir,
37
+ layouts: string[] = [],
38
+ out: RouteEntry[] = [],
39
+ loading?: string,
40
+ ): Promise<RouteEntry[]> {
41
+ const ownLayout = segmentFile(dir, "layout");
42
+ const chain = ownLayout ? [...layouts, ownLayout] : layouts;
43
+ const nearestLoading = segmentFile(dir, "loading") ?? loading;
44
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
45
+ for (const e of entries) {
46
+ if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
47
+ const full = join(dir, e.name);
48
+ if (e.isDirectory()) {
49
+ await scanRoutes(appDir, full, chain, out, nearestLoading);
50
+ continue;
51
+ }
52
+ const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
53
+ if (!ROUTE_EXTS.includes(ext)) continue;
54
+ const base = basename(e.name, ext);
55
+ const resource = base === "route";
56
+ if (!PAGE_BASENAMES.has(base) && !resource) continue;
57
+ const relDir = relative(appDir, dir);
58
+ const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
59
+ const path = "/" + segments.join("/");
60
+ out.push({
61
+ path: path === "/" ? "/" : path,
62
+ file: full,
63
+ dynamic: /\[.+\]/.test(path),
64
+ resource,
65
+ layouts: chain,
66
+ loading: nearestLoading,
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // Merge routes: app/ takes priority over .june/routes/ (app/ is the escape hatch).
73
+ // .june/routes/ is the convention slot for framework-generated routes (e.g. kura
74
+ // writes its docs/search/og routes there so the user never manages boilerplate).
75
+ export async function scanAppRoutes(appRoot: string): Promise<RouteEntry[]> {
76
+ const appDir = join(appRoot, "app");
77
+ const juneRoutesDir = join(appRoot, ".june", "routes");
78
+ const appRoutes = await scanRoutes(appDir);
79
+ const frameworkRoutes = existsSync(juneRoutesDir) ? await scanRoutes(juneRoutesDir) : [];
80
+ const appPaths = new Set(appRoutes.map((r) => r.path));
81
+ return [...appRoutes, ...frameworkRoutes.filter((r) => !appPaths.has(r.path))].sort((a, b) =>
82
+ a.path.localeCompare(b.path),
83
+ );
84
+ }
package/src/worker.ts CHANGED
@@ -17,10 +17,19 @@ import type { DocumentConfig } from "@junejs/core/document";
17
17
  import type { I18nConfig } from "@junejs/core/i18n";
18
18
  import type { Resources } from "@junejs/core/resources";
19
19
 
20
+ import type { Channel, ChannelFactory } from "@junejs/core/agent-config";
21
+
20
22
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
21
- import { durableAgentSurface, type DurableObjectNamespace } from "./agent-durable";
23
+ import { durableAgentSurface, durableChannelSurface, type DurableObjectNamespace } from "./agent-durable";
22
24
  import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
23
25
 
26
+ // The slice of workerd's ExecutionContext the worker threads through: webhook
27
+ // channels ACK fast and finish their work (post the reply out-of-band) on
28
+ // waitUntil — without it a floating promise can be killed when the isolate is
29
+ // reclaimed. Optional end to end; a host without it (tests, other targets)
30
+ // simply doesn't extend the invocation.
31
+ export type WorkerExecutionContext = { waitUntil?(p: Promise<unknown>): void };
32
+
24
33
  export type WorkerManifest = {
25
34
  // Static paths → route definitions ("/", "/users", ...).
26
35
  routes: Record<string, BrandedRoute>;
@@ -45,6 +54,13 @@ export type WorkerManifest = {
45
54
  // by the build when an agent/ directory exists; absent → no durable agent on
46
55
  // the edge. The DO namespace binding is `env.AGENT`.
47
56
  agentName?: string;
57
+ // The agent's channels (Channel or `(env) => Channel` factories, from the
58
+ // compiled agent module) — mounted as webhook routes on the worker via
59
+ // durableChannelSurface when agent.runtime.channels is on. The SAME factories
60
+ // are also wired into the DO (DoAgentDef.channels) by the generated entry, so
61
+ // capability tools and host-side delivery work; this field is the WORKER half
62
+ // (verify the webhook, run the turn on the DO, reply on waitUntil).
63
+ agentChannels?: (Channel | ChannelFactory)[];
48
64
  // Locale routing config, frozen from june.config.ts `i18n`. The locales table
49
65
  // is plain data; a `resolveLocale` hook survives the in-process freeze (parity
50
66
  // test) but not JSON codegen — worker hook support lands with the codegen pass.
@@ -111,7 +127,7 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
111
127
 
112
128
  export function createWorker(
113
129
  manifest: WorkerManifest,
114
- ): { fetch(request: Request, env?: unknown): Promise<Response> } {
130
+ ): { fetch(request: Request, env?: unknown, ctx?: WorkerExecutionContext): Promise<Response> } {
115
131
  const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
116
132
  ...compilePattern(d.pattern),
117
133
  def: d.def,
@@ -134,8 +150,14 @@ export function createWorker(
134
150
 
135
151
  // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
136
152
  // requests in an isolate; we capture the latest and hand it to the env-aware
137
- // provider, which memoizes the opened handles on first call.
153
+ // provider, which memoizes the opened handles on first call. That "latest"
154
+ // pattern is safe ONLY for per-isolate-stable values — concurrent fetches
155
+ // interleave at await points, so anything per-INVOCATION (the execution
156
+ // context, and env used at a specific moment) must come from request-scoped
157
+ // storage instead: `requestLocals`, keyed by the Request identity that flows
158
+ // unchanged from fetch() through the pipeline to the agent surface.
138
159
  let currentEnv: unknown;
160
+ const requestLocals = new WeakMap<Request, { env: unknown; ctx?: WorkerExecutionContext }>();
139
161
  const provider = manifest.resources;
140
162
 
141
163
  // App services: the build sets a raw `(env) => services` factory here; memoize it per
@@ -144,18 +166,43 @@ export function createWorker(
144
166
  const servicesProvider = manifest.services;
145
167
  let servicesCache: { v: unknown } | undefined;
146
168
 
147
- // Durable agent (edge): route the chat endpoint to the per-session DO bound at
148
- // env.AGENT. Built only when the app has an agent (manifest.agentName) and the
149
- // runtime is enabled; the surface is inert (null → fall through) until env.AGENT
150
- // is present, so a worker without the DO binding is unaffected.
169
+ // Durable agent (edge): route the chat endpoint — and the agent's channel
170
+ // webhooks — to the per-session DO bound at env.AGENT. Built only when the app
171
+ // has an agent (manifest.agentName) and the runtime is enabled; the surface is
172
+ // inert (null → fall through) until env.AGENT is present, so a worker without
173
+ // the DO binding is unaffected.
151
174
  const agentSurface =
152
175
  manifest.agent.runtime.enabled && manifest.agentName
153
176
  ? (() => {
154
- const route = durableAgentSurface(
155
- () => (currentEnv as { AGENT?: DurableObjectNamespace } | undefined)?.AGENT,
156
- { agentName: manifest.agentName, chatPath: manifest.agent.runtime.chat.path },
157
- );
158
- return (req: Request) => route(req);
177
+ const agentName = manifest.agentName;
178
+ const wantChannels = manifest.agent.runtime.channels !== false && (manifest.agentChannels?.length ?? 0) > 0;
179
+ return async (req: Request): Promise<Response | null> => {
180
+ // REQUEST-scoped, not isolate-scoped: the pipeline awaits between
181
+ // fetch() and this call, so a concurrent fetch may have run since —
182
+ // a module-level "current request" variable here would hand this
183
+ // webhook ANOTHER request's env (wrong AGENT namespace, factories
184
+ // resolved against foreign secrets) and register its background
185
+ // work on another invocation's waitUntil. Everything below reads
186
+ // THIS request's locals.
187
+ const locals = requestLocals.get(req);
188
+ const ns = () => (locals?.env as { AGENT?: DurableObjectNamespace } | undefined)?.AGENT;
189
+ const chat = durableAgentSurface(ns, { agentName, chatPath: manifest.agent.runtime.chat.path });
190
+ const chatRes = await chat(req);
191
+ if (chatRes) return chatRes;
192
+ if (!wantChannels) return null;
193
+ // Constructed per request — cheap by design: resolveChannel is a
194
+ // call, and resolveServices memoizes the app services bag per
195
+ // (env, agentName), exactly for this construction pattern (see
196
+ // durableChannelSurface's services doc).
197
+ const channels = durableChannelSurface(ns, {
198
+ agentName,
199
+ channels: manifest.agentChannels!,
200
+ env: locals?.env,
201
+ services: servicesProvider,
202
+ waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx),
203
+ });
204
+ return channels(req);
205
+ };
159
206
  })()
160
207
  : undefined;
161
208
 
@@ -224,8 +271,9 @@ export function createWorker(
224
271
  });
225
272
 
226
273
  return {
227
- fetch(request, env) {
228
- currentEnv = env;
274
+ fetch(request, env, ctx) {
275
+ currentEnv = env; // per-isolate-stable consumers only (resources/services)
276
+ requestLocals.set(request, { env, ctx }); // per-invocation consumers (agent surface)
229
277
  return pipeline.fetch(request);
230
278
  },
231
279
  };
@@ -241,16 +289,16 @@ export function createWorker(
241
289
  // dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
242
290
  // is a transparent pass-through to the pipeline.
243
291
  type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
244
- type FetchPipeline = { fetch(request: Request, env?: unknown): Promise<Response> };
292
+ type FetchPipeline = { fetch(request: Request, env?: unknown, ctx?: WorkerExecutionContext): Promise<Response> };
245
293
 
246
294
  const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
247
295
 
248
296
  export function withAssets(
249
297
  pipeline: FetchPipeline,
250
298
  opts: { link?: string | null } = {},
251
- ): { fetch(request: Request, env?: AssetEnv): Promise<Response> } {
299
+ ): { fetch(request: Request, env?: AssetEnv, ctx?: WorkerExecutionContext): Promise<Response> } {
252
300
  return {
253
- async fetch(request, env) {
301
+ async fetch(request, env, ctx) {
254
302
  const assets = env?.ASSETS;
255
303
  const url = new URL(request.url);
256
304
  const isPagePath = !/\.[a-z0-9]+$/i.test(url.pathname); // no file extension
@@ -296,7 +344,7 @@ export function withAssets(
296
344
 
297
345
  // 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
298
346
  // Env flows through so the pipeline's resources get the D1/KV/R2 bindings.
299
- return pipeline.fetch(request, env);
347
+ return pipeline.fetch(request, env, ctx);
300
348
  },
301
349
  };
302
350
  }