@junejs/server 0.1.0-dev.5 → 0.1.0-dev.7

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.
package/dist/adapter.d.ts CHANGED
@@ -23,6 +23,7 @@ type AdapterEmitContext = {
23
23
  config: JuneConfig;
24
24
  plan: ResourcePlan;
25
25
  defaultName: string;
26
+ publicFiles?: string[];
26
27
  };
27
28
  interface JuneAdapter {
28
29
  readonly name: string;
package/dist/adapter.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { copyFile, cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  //#region src/adapter.ts
5
5
  function workers(opts) {
6
6
  return {
@@ -78,7 +78,7 @@ function vercel(opts) {
78
78
  wrap: (pipelineVar) => isEdge ? env + `export default (request) => ${pipelineVar}.fetch(request, __env);` : env + `export default { fetch: (request) => ${pipelineVar}.fetch(request, __env) };`
79
79
  };
80
80
  },
81
- async emit({ appRoot, outDir, hasAssets }) {
81
+ async emit({ appRoot, outDir, hasAssets, publicFiles }) {
82
82
  const out = join(appRoot, ".vercel", "output");
83
83
  const fnDir = join(out, "functions", `${VERCEL_FUNCTION}.func`);
84
84
  await rm(out, {
@@ -106,6 +106,13 @@ function vercel(opts) {
106
106
  const src = join(outDir, "assets", "_june");
107
107
  if (existsSync(src)) await cp(src, join(out, "static", "_june"), { recursive: true });
108
108
  }
109
+ for (const rel of publicFiles ?? []) {
110
+ const from = join(outDir, "assets", rel);
111
+ if (!existsSync(from)) continue;
112
+ const to = join(out, "static", rel);
113
+ await mkdir(dirname(to), { recursive: true });
114
+ await copyFile(from, to);
115
+ }
109
116
  await writeFile(join(out, "config.json"), JSON.stringify({
110
117
  version: 3,
111
118
  routes: [
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { basename, extname, join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { defineAgent } from "@junejs/core/agent-config";
5
+ import { defineAgent, resolveChannel } from "@junejs/core/agent-config";
6
6
  import { connectAll } from "@junejs/core/connections";
7
7
  //#region src/agent-discover.ts
8
8
  async function scan(dir, ext) {
@@ -48,7 +48,7 @@ async function discoverAgent(dir) {
48
48
  const channels = [];
49
49
  for (const f of await scan(join(dir, "channels"), ".ts")) {
50
50
  const mod = await import(pathToFileURL(f).href);
51
- if (mod.default) channels.push(mod.default);
51
+ if (mod.default) channels.push(resolveChannel(mod.default, process.env));
52
52
  }
53
53
  const connectionDefs = [];
54
54
  for (const f of await scan(join(dir, "connections"), ".ts")) {
@@ -1,4 +1,6 @@
1
+ import { Channel, ChannelFactory } from "@junejs/core/agent-config";
1
2
  import { Model, Msg, SessionStore, Tool } from "@junejs/core/agent-runtime";
3
+ import { Resources } from "@junejs/core/resources";
2
4
  //#region src/agent-durable.d.ts
3
5
  interface SqlStorageCursor<T = Record<string, unknown>> {
4
6
  toArray(): T[];
@@ -40,9 +42,13 @@ type DoAgentDef = {
40
42
  model: Model;
41
43
  tools: Tool[];
42
44
  instructions?: string;
45
+ resources?: Resources;
46
+ services?: unknown;
43
47
  };
44
48
  declare class AgentDurableObject {
45
49
  private session;
50
+ private readonly resources;
51
+ private readonly services;
46
52
  constructor(state: DurableObjectState, def: DoAgentDef);
47
53
  turn(input: {
48
54
  turnId?: string;
@@ -56,5 +62,11 @@ declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace
56
62
  agentName: string;
57
63
  chatPath?: string;
58
64
  }): (req: Request) => Promise<Response | null>;
65
+ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
66
+ agentName: string;
67
+ channels: (Channel | ChannelFactory)[];
68
+ env: unknown;
69
+ waitUntil?: (p: Promise<unknown>) => void;
70
+ }): (req: Request) => Promise<Response | null>;
59
71
  //#endregion
60
- export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, durableAgentSurface, durableFetch };
72
+ export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, durableAgentSurface, durableChannelSurface, durableFetch };
@@ -1,4 +1,6 @@
1
+ import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
1
2
  import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
3
+ import { ensureScope, runInScope } from "@junejs/db";
2
4
  //#region src/agent-durable.ts
3
5
  var DoSessionStore = class {
4
6
  storage;
@@ -61,13 +63,21 @@ const crossDoUnsupported = { session() {
61
63
  } };
62
64
  var AgentDurableObject = class {
63
65
  session;
66
+ resources;
67
+ services;
64
68
  constructor(state, def) {
65
69
  const store = new DoSessionStore(state.storage);
66
70
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
71
+ this.resources = def.resources ?? {};
72
+ this.services = def.services;
67
73
  this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcBroadcaster(), model, def.tools, crossDoUnsupported);
68
74
  }
69
- turn(input) {
70
- return this.session.turn(input);
75
+ async turn(input) {
76
+ await ensureScope();
77
+ return runInScope({
78
+ resources: this.resources,
79
+ services: this.services
80
+ }, () => this.session.turn(input));
71
81
  }
72
82
  transcript() {
73
83
  return this.session.transcript();
@@ -102,5 +112,30 @@ function durableAgentSurface(getNamespace, opts) {
102
112
  }));
103
113
  };
104
114
  }
115
+ function durableChannelSurface(getNamespace, opts) {
116
+ return channelDispatch(opts.channels.map((c) => resolveChannel(c, opts.env)), {
117
+ agent: {
118
+ name: opts.agentName,
119
+ instructions: "",
120
+ tools: [],
121
+ skills: [],
122
+ channels: [],
123
+ connections: []
124
+ },
125
+ waitUntil: opts.waitUntil,
126
+ run: async (message, o) => {
127
+ const namespace = getNamespace();
128
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
129
+ const { text } = await (await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
130
+ method: "POST",
131
+ body: JSON.stringify({
132
+ userText: message,
133
+ turnId: o?.turnId
134
+ })
135
+ }))).json();
136
+ return text;
137
+ }
138
+ });
139
+ }
105
140
  //#endregion
106
- export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableFetch };
141
+ export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch };
package/dist/app.js CHANGED
@@ -8,7 +8,9 @@ import { MODULE_STYLES_URL, buildModuleCss, registerCssModules } from "./css-mod
8
8
  import { CLIENT_SCRIPT_URL, bundleClientSplit, findClientEntry } from "./client-bundle.js";
9
9
  import { generateIslandRegistry } from "./island-registry.js";
10
10
  import { STYLES_URL, findGlobalCss, globalCssUsesTailwind, processCssCached } from "./css.js";
11
- import { existsSync } from "node:fs";
11
+ import { contentTypeFor, safeRelativePath } from "./static-files.js";
12
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
12
14
  import { dirname, join, resolve } from "node:path";
13
15
  import { pathToFileURL } from "node:url";
14
16
  import { buildSystemPrompt } from "@junejs/core/agent-config";
@@ -62,6 +64,11 @@ function createApp({ appDir: appDirInput, config = {} }) {
62
64
  const appDir = resolve(appDirInput);
63
65
  const juneRoutesDir = join(dirname(appDir), ".june", "routes");
64
66
  const hasJuneRoutes = existsSync(juneRoutesDir);
67
+ const publicDir = join(dirname(appDir), "public");
68
+ let publicRoot = null;
69
+ try {
70
+ if (lstatSync(publicDir).isDirectory()) publicRoot = realpathSync(publicDir);
71
+ } catch {}
65
72
  const agent = resolveAgent(config.agent);
66
73
  const speculation = config.speculation;
67
74
  const clientEntry = findClientEntry(appDir) ?? findClientEntry(join(dirname(appDir), ".june", "routes"));
@@ -201,7 +208,34 @@ function createApp({ appDir: appDirInput, config = {} }) {
201
208
  });
202
209
  }
203
210
  }
204
- return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
211
+ const toPipeline = () => runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
212
+ if (publicRoot !== null && (request.method === "GET" || request.method === "HEAD")) {
213
+ const rel = safeRelativePath(new URL(request.url).pathname);
214
+ if (rel !== null && rel.split("/")[0] !== "_june") {
215
+ const segments = rel.split("/");
216
+ const file = join(publicRoot, ...segments);
217
+ let stat;
218
+ try {
219
+ stat = lstatSync(file);
220
+ } catch {}
221
+ let real = null;
222
+ if (stat?.isFile()) try {
223
+ real = realpathSync(file);
224
+ } catch {}
225
+ if (real === file) {
226
+ const headers = {
227
+ "content-type": contentTypeFor(rel),
228
+ "cache-control": "public, max-age=0, must-revalidate"
229
+ };
230
+ if (request.method === "HEAD") return Promise.resolve(new Response(null, { headers: {
231
+ ...headers,
232
+ "content-length": String(stat.size)
233
+ } }));
234
+ return readFile(file).then((buf) => new Response(buf, { headers }), () => toPipeline());
235
+ }
236
+ }
237
+ }
238
+ return toPipeline();
205
239
  },
206
240
  async warmup() {
207
241
  const files = await routeFiles(appDir, { pageConvention: true });
package/dist/build.js CHANGED
@@ -6,12 +6,13 @@ import { jsxTransform } from "./tsconfig-jsx.js";
6
6
  import { CLIENT_SCRIPT_URL, bundleClientToFile, findClientEntry } from "./client-bundle.js";
7
7
  import { generateIslandRegistry } from "./island-registry.js";
8
8
  import { STYLES_URL, cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss } from "./css.js";
9
+ import { RESERVED_PREFIX } from "./static-files.js";
9
10
  import { findJuneConfigPath, loadJuneConfig } from "./config-loader.js";
10
11
  import { generateContentModule } from "./content.js";
11
12
  import { createWorker } from "./worker.js";
12
13
  import { buildRsc, findRscRoutes } from "./rsc-build.js";
13
- import { existsSync } from "node:fs";
14
- import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
14
+ import { existsSync, lstatSync } from "node:fs";
15
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
15
16
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
17
  import { pathToFileURL } from "node:url";
17
18
  import { routeFromModule } from "@junejs/core/route";
@@ -279,6 +280,15 @@ function importPath(fromDir, file) {
279
280
  const p = relative(fromDir, file).split(sep).join("/").replace(/\.[^.]+$/, "");
280
281
  return p.startsWith(".") ? p : `./${p}`;
281
282
  }
283
+ async function collectFiles(dir, base = dir) {
284
+ const out = [];
285
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
286
+ const abs = join(dir, entry.name);
287
+ if (entry.isDirectory()) out.push(...await collectFiles(abs, base));
288
+ else if (entry.isFile()) out.push(relative(base, abs).split(sep).join("/"));
289
+ }
290
+ return out;
291
+ }
282
292
  async function juneBuild(appRoot, options = {}) {
283
293
  const appDir = join(appRoot, "app");
284
294
  if (!existsSync(appDir)) throw new Error(`no app/ directory in ${appRoot} — is this a June app?`);
@@ -459,6 +469,26 @@ ${adapterEntry.wrap("pipeline")}
459
469
  if (moduleCssAsset) manifest.document.moduleStyles = `/${moduleCssAsset}`;
460
470
  const worker = createWorker(manifest);
461
471
  let hasAssets = false;
472
+ const publicDir = join(appRoot, "public");
473
+ const publicFiles = [];
474
+ let publicIsDir = false;
475
+ try {
476
+ publicIsDir = lstatSync(publicDir).isDirectory();
477
+ } catch {}
478
+ if (existsSync(publicDir) && !publicIsDir) console.warn(`[june] public/ is not a real directory (symlink?) — skipped`);
479
+ if (publicIsDir) {
480
+ for (const rel of await collectFiles(publicDir)) {
481
+ if (rel.split("/")[0] === "_june") {
482
+ console.warn(`[june] public/${rel} ignored — ${RESERVED_PREFIX}/ is reserved for framework assets`);
483
+ continue;
484
+ }
485
+ const dest = join(assetsDir, rel);
486
+ await mkdir(dirname(dest), { recursive: true });
487
+ await copyFile(join(publicDir, ...rel.split("/")), dest);
488
+ publicFiles.push(rel);
489
+ }
490
+ if (publicFiles.length) hasAssets = true;
491
+ }
462
492
  const isStatic = adapter.capabilities.runtime === "static";
463
493
  const i18n = frozen.i18n;
464
494
  const prerenderOne = async (reqPath, def) => {
@@ -536,7 +566,8 @@ ${adapterEntry.wrap("pipeline")}
536
566
  linkHeader,
537
567
  config: fullConfig,
538
568
  plan,
539
- defaultName
569
+ defaultName,
570
+ publicFiles
540
571
  });
541
572
  if (plan.db) console.log(` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`);
542
573
  return {
package/dist/index.d.ts CHANGED
@@ -21,6 +21,6 @@ import { ResourceFlags, WorkerEnv, bindWorkerResources, memoizeResources } from
21
21
  import { MatchOptions, RouteMatch, RouteTreeMatch, SegmentMatch, listRoutes, matchRoute, matchRouteTree, resolveNotFound } from "./router.js";
22
22
  import { installAsyncContext } from "./instrumentation.js";
23
23
  import { Negotiated, negotiate } from "./negotiate.js";
24
- import { blob, db, kv } from "@junejs/db";
24
+ import { blob, currentServices, db, kv } from "@junejs/db";
25
25
  import { I18nConfig, LOCALE_COOKIE, LocaleConfig, LocaleMatch, dirForLang, localeDir, localeHref, matchPinnedLocale, negotiateLocale, parseAcceptLanguage, resolveRequestLocale } from "@junejs/core/i18n";
26
- export { type AdapterCapabilities, type AdapterEmitContext, type AdapterEntry, type BuildResult, type ContentEntry, type CreateAppOptions, type D1Database, type D1Exec, type DeployResult, type DevServer, type DevServerOptions, type I18nConfig, type JuneAdapter, type JuneApp, type JuneDb, type JuneHost, LOCALE_COOKIE, type LayoutComponent, type LocaleConfig, type LocaleMatch, type MatchOptions, type MigrateResult, type Migration, type Negotiated, type Pipeline, type PipelineConfig, type R2Bucket, type Resolved, type ResourceFlags, type ResourcePlan, type RouteMatch, type RouteResolver, type RouteTreeMatch, type RunResult, type SegmentMatch, type ServeHandle, type SpawnedModule, type WorkerEnv, type WorkerManifest, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, d1, db, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
26
+ export { type AdapterCapabilities, type AdapterEmitContext, type AdapterEntry, type BuildResult, type ContentEntry, type CreateAppOptions, type D1Database, type D1Exec, type DeployResult, type DevServer, type DevServerOptions, type I18nConfig, type JuneAdapter, type JuneApp, type JuneDb, type JuneHost, LOCALE_COOKIE, type LayoutComponent, type LocaleConfig, type LocaleMatch, type MatchOptions, type MigrateResult, type Migration, type Negotiated, type Pipeline, type PipelineConfig, type R2Bucket, type Resolved, type ResourceFlags, type ResourcePlan, type RouteMatch, type RouteResolver, type RouteTreeMatch, type RunResult, type SegmentMatch, type ServeHandle, type SpawnedModule, type WorkerEnv, type WorkerManifest, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
package/dist/index.js CHANGED
@@ -21,6 +21,6 @@ import { installAsyncContext } from "./instrumentation.js";
21
21
  import { host } from "./host.js";
22
22
  import { startDevServer } from "./dev.js";
23
23
  import { memoryKv, redisKv } from "./kv.js";
24
- import { blob, db, kv } from "@junejs/db";
24
+ import { blob, currentServices, db, kv } from "@junejs/db";
25
25
  import { LOCALE_COOKIE, dirForLang, localeDir, localeHref, matchPinnedLocale, negotiateLocale, parseAcceptLanguage, resolveRequestLocale } from "@junejs/core/i18n";
26
- export { LOCALE_COOKIE, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, d1, db, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
26
+ export { LOCALE_COOKIE, bindWorkerResources, blob, blockedMessage, buildManifest, classify, collection, createApp, createPipeline, createWorker, currentServices, d1, db, deno, dirForLang, entry, freezeConfig, generateContent, host, installAsyncContext, juneBuild, juneDeploy, kv, listRoutes, loadJuneConfig, localBlob, localeDir, localeHref, matchPinnedLocale, matchRoute, matchRouteTree, memoizeResources, memoryKv, migrate, migrateApp, migrateD1, mysql, negotiate, negotiateLocale, parseAcceptLanguage, postgres, r2, readMigrations, redisKv, resolveD1Database, resolveNotFound, resolveRequestLocale, scanRoutes, sqlite, startDevServer, staticSite, turso, typesApp, vercel, workers, wranglerD1 };
package/dist/pipeline.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { SEGMENT_HEADER, TITLE_HEADER, encodeTitle, negotiate } from "./negotiate.js";
2
+ import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
2
3
  import { LoaderDataContext } from "@junejs/core/route";
3
4
  import React, { Suspense, use } from "react";
4
5
  import { renderToReadableStream } from "react-dom/server";
5
6
  import { Document } from "@junejs/core/document";
6
7
  import { apiCatalog, buildLinkHeader, llmsTxt, mcpServerCard, robotsTxt, sitemapXml } from "@junejs/core/discovery";
7
8
  import { mcpHandler, mcpTools } from "@junejs/core/mcp";
8
- import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
9
9
  import { LOCALE_COOKIE, localeAlternates, localeDir, matchPinnedLocale, negotiateLocale } from "@junejs/core/i18n";
10
10
  //#region src/pipeline.ts
11
11
  function readCookie(request, name) {
@@ -0,0 +1,6 @@
1
+ //#region src/static-files.d.ts
2
+ declare const RESERVED_PREFIX = "_june";
3
+ declare function contentTypeFor(pathname: string): string;
4
+ declare function safeRelativePath(pathname: string): string | null;
5
+ //#endregion
6
+ export { RESERVED_PREFIX, contentTypeFor, safeRelativePath };
@@ -0,0 +1,56 @@
1
+ //#region src/static-files.ts
2
+ const RESERVED_PREFIX = "_june";
3
+ const CONTENT_TYPES = {
4
+ html: "text/html; charset=utf-8",
5
+ css: "text/css; charset=utf-8",
6
+ js: "text/javascript; charset=utf-8",
7
+ mjs: "text/javascript; charset=utf-8",
8
+ json: "application/json; charset=utf-8",
9
+ map: "application/json; charset=utf-8",
10
+ xml: "application/xml; charset=utf-8",
11
+ txt: "text/plain; charset=utf-8",
12
+ md: "text/markdown; charset=utf-8",
13
+ csv: "text/csv; charset=utf-8",
14
+ webmanifest: "application/manifest+json",
15
+ svg: "image/svg+xml",
16
+ png: "image/png",
17
+ jpg: "image/jpeg",
18
+ jpeg: "image/jpeg",
19
+ gif: "image/gif",
20
+ webp: "image/webp",
21
+ avif: "image/avif",
22
+ ico: "image/x-icon",
23
+ bmp: "image/bmp",
24
+ woff2: "font/woff2",
25
+ woff: "font/woff",
26
+ ttf: "font/ttf",
27
+ otf: "font/otf",
28
+ mp4: "video/mp4",
29
+ webm: "video/webm",
30
+ mp3: "audio/mpeg",
31
+ ogg: "audio/ogg",
32
+ wav: "audio/wav",
33
+ wasm: "application/wasm",
34
+ pdf: "application/pdf",
35
+ zip: "application/zip"
36
+ };
37
+ function contentTypeFor(pathname) {
38
+ const dot = pathname.lastIndexOf(".");
39
+ if (dot === -1) return "application/octet-stream";
40
+ return CONTENT_TYPES[pathname.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
41
+ }
42
+ function safeRelativePath(pathname) {
43
+ let decoded;
44
+ try {
45
+ decoded = decodeURIComponent(pathname);
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (decoded.includes("\0") || decoded.includes("\\")) return null;
50
+ const segments = decoded.split("/").filter((s) => s !== "" && s !== ".");
51
+ if (segments.length === 0) return null;
52
+ if (segments.some((s) => s === "..")) return null;
53
+ return segments.join("/");
54
+ }
55
+ //#endregion
56
+ export { RESERVED_PREFIX, contentTypeFor, safeRelativePath };
package/dist/worker.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { durableAgentSurface } from "./agent-durable.js";
2
2
  import { createPipeline } from "./pipeline.js";
3
+ import { RESERVED_PREFIX, contentTypeFor, safeRelativePath } from "./static-files.js";
3
4
  //#region src/worker.ts
4
5
  function compilePattern(pattern) {
5
6
  const names = [];
@@ -176,32 +177,33 @@ function withAssets(pipeline, opts = {}) {
176
177
  return pipeline.fetch(request, env);
177
178
  } };
178
179
  }
179
- const ASSET_CONTENT_TYPES = {
180
- css: "text/css; charset=utf-8",
181
- js: "text/javascript; charset=utf-8",
182
- json: "application/json; charset=utf-8",
183
- map: "application/json; charset=utf-8",
184
- svg: "image/svg+xml",
185
- png: "image/png",
186
- jpg: "image/jpeg",
187
- jpeg: "image/jpeg",
188
- webp: "image/webp",
189
- avif: "image/avif",
190
- ico: "image/x-icon",
191
- woff2: "font/woff2",
192
- woff: "font/woff"
193
- };
194
180
  function withDenoAssets(pipeline) {
195
181
  return async (request) => {
196
- const url = new URL(request.url);
197
- if (request.method === "GET" && url.pathname.startsWith("/_june/")) try {
198
- const file = await Deno.readFile(new URL(`./assets${url.pathname}`, import.meta.url));
199
- const ext = url.pathname.split(".").pop()?.toLowerCase() ?? "";
200
- return new Response(file, { headers: {
201
- "content-type": ASSET_CONTENT_TYPES[ext] ?? "application/octet-stream",
202
- "cache-control": "public, max-age=31536000, immutable"
203
- } });
204
- } catch {}
182
+ const method = request.method;
183
+ if (method === "GET" || method === "HEAD") {
184
+ const url = new URL(request.url);
185
+ {
186
+ const rel = safeRelativePath(url.pathname);
187
+ if (rel !== null) try {
188
+ const assetUrl = new URL(`./assets/${rel.split("/").map(encodeURIComponent).join("/")}`, import.meta.url);
189
+ const immutable = rel.startsWith(`${RESERVED_PREFIX}/`);
190
+ const headers = {
191
+ "content-type": contentTypeFor(rel),
192
+ "cache-control": immutable ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate"
193
+ };
194
+ if (method === "HEAD") {
195
+ const info = await Deno.stat(assetUrl);
196
+ if (!info.isFile) throw new Error("not a file");
197
+ return new Response(null, { headers: {
198
+ ...headers,
199
+ "content-length": String(info.size)
200
+ } });
201
+ }
202
+ const file = await Deno.readFile(assetUrl);
203
+ return new Response(file, { headers });
204
+ } catch {}
205
+ }
206
+ }
205
207
  return pipeline.fetch(request);
206
208
  };
207
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.0-dev.5",
3
+ "version": "0.1.0-dev.7",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -125,8 +125,8 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.0-dev.4",
129
- "@junejs/db": ">=0.0.33-dev.1",
128
+ "@junejs/core": ">=0.1.0-dev.5",
129
+ "@junejs/db": ">=0.0.33-dev.2",
130
130
  "@momiji-rs/sparkdown": "^0.0.4",
131
131
  "lightningcss": "^1.27.0",
132
132
  "oxc-parser": "^0.137.0",
package/src/adapter.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // are explicit `deploy: { adapter: vercel() }` from their own package.
9
9
  import { existsSync } from "node:fs";
10
10
  import { cp, copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
11
- import { basename, join } from "node:path";
11
+ import { basename, dirname, join } from "node:path";
12
12
 
13
13
  import type { JuneConfig } from "@junejs/core/config";
14
14
 
@@ -49,6 +49,10 @@ export type AdapterEmitContext = {
49
49
  plan: ResourcePlan;
50
50
  // package.json name (or sanitized dir name) — the fallback worker/app name.
51
51
  defaultName: string;
52
+ // Verbatim public/ files (forward-slash paths relative to assets/), already
53
+ // copied into outDir/assets. Adapters that build a separate static tier (vercel)
54
+ // place these there; whole-assets targets (workers/static/deno) ignore it.
55
+ publicFiles?: string[];
52
56
  };
53
57
 
54
58
  export interface JuneAdapter {
@@ -214,7 +218,7 @@ export function vercel(opts?: { runtime?: "node" | "edge"; regions?: string[] })
214
218
  };
215
219
  },
216
220
 
217
- async emit({ appRoot, outDir, hasAssets }) {
221
+ async emit({ appRoot, outDir, hasAssets, publicFiles }) {
218
222
  const out = join(appRoot, ".vercel", "output");
219
223
  const fnDir = join(out, "functions", `${VERCEL_FUNCTION}.func`);
220
224
  await rm(out, { recursive: true, force: true }); // clean — no stale outputs
@@ -249,11 +253,20 @@ export function vercel(opts?: { runtime?: "node" | "edge"; regions?: string[] })
249
253
  await writeFile(join(fnDir, "package.json"), JSON.stringify({ type: "module" }, null, 2) + "\n");
250
254
  }
251
255
 
252
- // static: only the hashed framework assets under /_june/ (exact-path URLs)
256
+ // static: the hashed framework assets under /_june/ (exact-path URLs)...
253
257
  if (hasAssets) {
254
258
  const src = join(outDir, "assets", "_june");
255
259
  if (existsSync(src)) await cp(src, join(out, "static", "_june"), { recursive: true });
256
260
  }
261
+ // ...plus verbatim public/ files, served by the `filesystem` route handle
262
+ // below. Only these — prerendered page HTML stays on the SSR function.
263
+ for (const rel of publicFiles ?? []) {
264
+ const from = join(outDir, "assets", rel);
265
+ if (!existsSync(from)) continue;
266
+ const to = join(out, "static", rel);
267
+ await mkdir(dirname(to), { recursive: true });
268
+ await copyFile(from, to);
269
+ }
257
270
 
258
271
  await writeFile(
259
272
  join(out, "config.json"),
@@ -21,7 +21,7 @@ import { basename, extname, join } from "node:path";
21
21
  import { pathToFileURL } from "node:url";
22
22
 
23
23
  import type { AnyAction } from "@junejs/core/agent";
24
- import { defineAgent, type AgentConfigFile, type AgentDefinition, type Channel, type Skill } from "@junejs/core/agent-config";
24
+ import { defineAgent, resolveChannel, type AgentConfigFile, type AgentDefinition, type Channel, type ChannelFactory, type Skill } from "@junejs/core/agent-config";
25
25
  import { connectAll, type Connection } from "@junejs/core/connections";
26
26
 
27
27
  async function scan(dir: string, ext: string): Promise<string[]> {
@@ -79,10 +79,13 @@ export async function discoverAgent(dir: string): Promise<AgentDefinition> {
79
79
  skills.push(parseSkill(basename(f, ".md"), await readFile(f, "utf8")));
80
80
  }
81
81
 
82
+ // A channel module default-exports a Channel OR a `(env) => Channel` factory (the
83
+ // form workerd needs — secrets live in env, not at module scope). On native, env is
84
+ // process.env (present at load), so resolve here; the plain form stays supported.
82
85
  const channels: Channel[] = [];
83
86
  for (const f of await scan(join(dir, "channels"), ".ts")) {
84
87
  const mod = await import(pathToFileURL(f).href);
85
- if (mod.default) channels.push(mod.default as Channel);
88
+ if (mod.default) channels.push(resolveChannel(mod.default as Channel | ChannelFactory, process.env));
86
89
  }
87
90
 
88
91
  // connections/*.ts — outbound: wire external MCP/OpenAPI servers, turning their
@@ -25,6 +25,16 @@ import {
25
25
  type SessionStore,
26
26
  type Tool,
27
27
  } from "@junejs/core/agent-runtime";
28
+ import type { Resources } from "@junejs/core/resources";
29
+ import {
30
+ channelDispatch,
31
+ resolveChannel,
32
+ type AgentDefinition,
33
+ type Channel,
34
+ type ChannelContext,
35
+ type ChannelFactory,
36
+ } from "@junejs/core/agent-config";
37
+ import { ensureScope, runInScope } from "@junejs/db";
28
38
 
29
39
  // ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
30
40
  export interface SqlStorageCursor<T = Record<string, unknown>> {
@@ -114,7 +124,26 @@ const crossDoUnsupported: Runtime = {
114
124
 
115
125
  // `instructions` (system prompt) is injected into the model per turn (withSystem),
116
126
  // so it need not be baked into `model` at construction — single-sourced on the def.
117
- export type DoAgentDef = { name?: string; model: Model; tools: Tool[]; instructions?: string };
127
+ //
128
+ // `resources` / `services` are the DI seam that closes the isolate gap: a DO is a
129
+ // SEPARATE isolate from the Worker entry, reached by RPC, so the pipeline's request
130
+ // scope (and its ambient `db`/`kv`/`blob`) never crosses into it. Instead the app
131
+ // builds these from the DO's OWN env in the DO constructor (where env lives) and the
132
+ // AgentDurableObject runs every turn inside a scope holding them — so a tool reads
133
+ // ambient `db` (from `resources`) and app-defined services (via `currentServices()`)
134
+ // exactly as a route loader does, with no module-global setter and no `env` on ctx.
135
+ // • resources — db/kv/blob handles bound from env (e.g. `{ db: d1(env.DB) }`).
136
+ // • services — the app's own bag for things June doesn't model (Vectorize,
137
+ // Workers AI, a ledger writer, a retriever). Opaque here; the app types it at
138
+ // the `currentServices<T>()` read.
139
+ export type DoAgentDef = {
140
+ name?: string;
141
+ model: Model;
142
+ tools: Tool[];
143
+ instructions?: string;
144
+ resources?: Resources;
145
+ services?: unknown;
146
+ };
118
147
 
119
148
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
120
149
  // DO state) so this module needs no `cloudflare:workers` import. The app supplies
@@ -123,18 +152,36 @@ export type DoAgentDef = { name?: string; model: Model; tools: Tool[]; instructi
123
152
  // AgentDurableObject from `this.ctx` and forwards `fetch()`:
124
153
  //
125
154
  // export class JuneAgentDO extends DurableObject {
126
- // agent = new AgentDurableObject(this.ctx, { model, tools });
155
+ // agent = new AgentDurableObject(this.ctx, {
156
+ // model, tools,
157
+ // resources: { db: d1(this.env.DB) }, // ambient `db` inside tools
158
+ // services: makeServices(this.env), // retriever, ledger writer, Vectorize…
159
+ // });
127
160
  // fetch(req) { return this.agent.fetch(req); }
128
161
  // }
129
162
  export class AgentDurableObject {
130
163
  private session: AgentSession;
164
+ // Built from the DO's env in the constructor (this isolate), shared across turns —
165
+ // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
166
+ private readonly resources: Resources;
167
+ private readonly services: unknown;
131
168
  constructor(state: DurableObjectState, def: DoAgentDef) {
132
169
  const store = new DoSessionStore(state.storage);
133
170
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
171
+ this.resources = def.resources ?? {};
172
+ this.services = def.services;
134
173
  this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcBroadcaster(), model, def.tools, crossDoUnsupported);
135
174
  }
136
- turn(input: { turnId?: string; userText: string }): Promise<string> {
137
- return this.session.turn(input);
175
+ // Run the whole turn inside a request scope seeded from this DO's env, so ambient
176
+ // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
177
+ // route loader. `locals` is intentionally NOT set here: a fresh scope object per
178
+ // turn means a fresh (lazily-created) locals Map per turn, so per-turn state (e.g.
179
+ // Juno's batch-loader registry) can't leak across turns on a long-lived DO.
180
+ // ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
181
+ // pipeline does; without it runInScope is a pass-through and ambient reads throw.
182
+ async turn(input: { turnId?: string; userText: string }): Promise<string> {
183
+ await ensureScope();
184
+ return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
138
185
  }
139
186
  transcript() {
140
187
  return this.session.transcript();
@@ -186,3 +233,60 @@ export function durableAgentSurface(
186
233
  );
187
234
  };
188
235
  }
236
+
237
+ // The EDGE channel surface: mount an agent's INBOUND channels (Slack/Crisp webhooks,
238
+ // an http endpoint) on the Worker entry and route each turn into the per-session
239
+ // Durable Object. The sibling of durableAgentSurface, which mounts only the chat
240
+ // endpoint; a worker composes both (`await chat(req) ?? await channels(req) ?? 404`).
241
+ //
242
+ // Channels are resolved from the Worker's `env`, because on workerd a signing secret
243
+ // lives only there (never at module top-level) — so a channel module can default-
244
+ // export a factory: `export default (env) => crispChannel({ signingSecret:
245
+ // env.CRISP_SIGNATURE_SECRET, ... })`. `waitUntil` (workerd's `ctx.waitUntil`) keeps
246
+ // the isolate alive for the fast-ACK background work (run the turn, post the reply
247
+ // back out). The webhook's own signature check still runs first, in the channel.
248
+ //
249
+ // export default {
250
+ // fetch(req, env, ctx) {
251
+ // const channels = durableChannelSurface(() => env.AGENT, {
252
+ // agentName: "crisp-support", channels: [crispCh], env,
253
+ // waitUntil: ctx.waitUntil.bind(ctx),
254
+ // });
255
+ // return channels(req).then((r) => r ?? new Response("not found", { status: 404 }));
256
+ // },
257
+ // }
258
+ //
259
+ // Note: if the DO namespace is unbound, a matched webhook still ACKs but the turn
260
+ // fails in the background — surfaced via the channel's onError IF one is configured,
261
+ // otherwise swallowed (runBackground never rejects). The app is expected to bind
262
+ // env.AGENT when it mounts channels. Returns null for unclaimed requests.
263
+ export function durableChannelSurface(
264
+ getNamespace: () => DurableObjectNamespace | undefined,
265
+ opts: {
266
+ agentName: string;
267
+ channels: (Channel | ChannelFactory)[];
268
+ env: unknown;
269
+ waitUntil?: (p: Promise<unknown>) => void;
270
+ },
271
+ ): (req: Request) => Promise<Response | null> {
272
+ const resolved = opts.channels.map((c) => resolveChannel(c, opts.env));
273
+ const ctx: ChannelContext = {
274
+ // A minimal but complete AgentDefinition — channels only read ctx.agent.name; the
275
+ // full def isn't present in the worker (tools/model live in the DO).
276
+ agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
277
+ waitUntil: opts.waitUntil,
278
+ run: async (message, o) => {
279
+ const namespace = getNamespace();
280
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
281
+ const res = await durableFetch(
282
+ namespace,
283
+ opts.agentName,
284
+ o?.session ?? "default",
285
+ new Request("https://do/turn", { method: "POST", body: JSON.stringify({ userText: message, turnId: o?.turnId }) }),
286
+ );
287
+ const { text } = (await res.json()) as { text: string };
288
+ return text;
289
+ },
290
+ };
291
+ return channelDispatch(resolved, ctx);
292
+ }
package/src/app.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  // all change observable output (test/config-output.test.ts) — the PoC shipped a
10
10
  // dev server that silently ignored june.config.ts for days.
11
11
 
12
- import { existsSync } from "node:fs";
12
+ import { existsSync, lstatSync, realpathSync, type Stats } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
13
14
  import { dirname, join, resolve } from "node:path";
14
15
  import { pathToFileURL } from "node:url";
15
16
  import type { ComponentType } from "react";
@@ -41,6 +42,7 @@ import {
41
42
  import { generateIslandRegistry } from "./island-registry";
42
43
  import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
43
44
  import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
45
+ import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
44
46
 
45
47
  export type CreateAppOptions = {
46
48
  appDir: string;
@@ -117,6 +119,20 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
117
119
  // app/ stays the priority/escape-hatch; this is the framework slot, consulted as a fallback.
118
120
  const juneRoutesDir = join(dirname(appDir), ".june", "routes");
119
121
  const hasJuneRoutes = existsSync(juneRoutesDir);
122
+ // App-root public/ (sibling of app/): verbatim static files served in dev the
123
+ // same way the platform serves them in prod. Copied into dist/assets at build.
124
+ // Resolve to the REAL directory once: if public/ itself is a symlink (e.g.
125
+ // `public -> ..`, a hostile template), path resolution would follow it and
126
+ // escape the app root — refuse to serve then (publicRoot stays null). The build
127
+ // ignores a symlinked public/ too, so this keeps dev/prod parity. Anchoring
128
+ // later reads on the realpath also lets us reject symlinked entries underneath.
129
+ const publicDir = join(dirname(appDir), "public");
130
+ let publicRoot: string | null = null;
131
+ try {
132
+ if (lstatSync(publicDir).isDirectory()) publicRoot = realpathSync(publicDir);
133
+ } catch {
134
+ /* no public/ dir → nothing to serve */
135
+ }
120
136
  const agent = resolveAgent(config.agent);
121
137
  const speculation = config.speculation;
122
138
  // app/_client.* present → the dev document loads /client.js and we serve it
@@ -321,7 +337,63 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
321
337
  );
322
338
  }
323
339
  }
324
- return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
340
+ // Static files under app-root public/, served verbatim. Mirrors the prod
341
+ // asset layer (Cloudflare ASSETS / Vercel filesystem / Deno withDenoAssets
342
+ // all answer before the pipeline), so a public/ file shadows a same-path
343
+ // route in dev exactly as it does when deployed. Passthrough — no hashing
344
+ // or optimization. GET/HEAD only; a miss falls through to the render core.
345
+ const toPipeline = () => runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
346
+ if (publicRoot !== null && (request.method === "GET" || request.method === "HEAD")) {
347
+ const rel = safeRelativePath(new URL(request.url).pathname);
348
+ if (rel !== null && rel.split("/")[0] !== RESERVED_PREFIX) {
349
+ const segments = rel.split("/");
350
+ const file = join(publicRoot, ...segments); // anchor on the REAL public root
351
+ // lstatSync (NOT statSync) gets existence+type+size in one syscall
352
+ // without following a leaf symlink. ENOENT / EACCES → treat as a miss.
353
+ let stat: Stats | undefined;
354
+ try {
355
+ stat = lstatSync(file);
356
+ } catch {
357
+ /* not a readable file → fall through */
358
+ }
359
+ // Serve only a real file whose resolved path is STILL inside publicRoot:
360
+ // realpathSync(file) === file holds iff no path component (leaf OR an
361
+ // intermediate dir) is a symlink. That blocks symlink escapes AND matches
362
+ // the build, which drops every symlinked entry under public/.
363
+ let real: string | null = null;
364
+ if (stat?.isFile()) {
365
+ try {
366
+ real = realpathSync(file);
367
+ } catch {
368
+ /* race: gone between stat and realpath → miss */
369
+ }
370
+ }
371
+ if (real === file) {
372
+ const headers = {
373
+ "content-type": contentTypeFor(rel),
374
+ // Not content-hashed → must revalidate, never immutable.
375
+ "cache-control": "public, max-age=0, must-revalidate",
376
+ };
377
+ // HEAD: existence + size already proven — answer without reading the
378
+ // body (a large asset would otherwise be read and discarded).
379
+ if (request.method === "HEAD") {
380
+ return Promise.resolve(
381
+ new Response(null, { headers: { ...headers, "content-length": String(stat!.size) } }),
382
+ );
383
+ }
384
+ // A file deleted/locked between stat and read (dev editors do atomic
385
+ // save = write-temp + rename) must not crash the request — fall
386
+ // through to the pipeline on a read error, same as any other miss.
387
+ // readFile returns a Buffer (a Uint8Array) — pass it straight through;
388
+ // re-wrapping in new Uint8Array(buf) would copy every byte.
389
+ return readFile(file).then(
390
+ (buf) => new Response(buf, { headers }),
391
+ () => toPipeline(),
392
+ );
393
+ }
394
+ }
395
+ }
396
+ return toPipeline();
325
397
  },
326
398
  async warmup() {
327
399
  const files = await routeFiles(appDir, { pageConvention: true });
package/src/build.ts CHANGED
@@ -19,8 +19,8 @@
19
19
 
20
20
  import { execFileSync } from "node:child_process";
21
21
  import { createHash } from "node:crypto";
22
- import { existsSync } from "node:fs";
23
- import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
22
+ import { existsSync, lstatSync } from "node:fs";
23
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
24
24
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
 
@@ -38,6 +38,7 @@ import { findMiddlewareFile } from "./router";
38
38
  import { resolveBoundary } from "./segment";
39
39
  import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
40
40
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
41
+ import { RESERVED_PREFIX } from "./static-files";
41
42
  import { jsxTransform } from "./tsconfig-jsx";
42
43
  import { generateIslandRegistry } from "./island-registry";
43
44
  import { buildRsc, findRscRoutes } from "./rsc-build";
@@ -460,6 +461,19 @@ function importPath(fromDir: string, file: string): string {
460
461
  return p.startsWith(".") ? p : `./${p}`;
461
462
  }
462
463
 
464
+ // Recursively list every file under `dir` as a forward-slash relative path. Used
465
+ // to enumerate public/ for the verbatim asset copy (dot-files included — a
466
+ // `.well-known/` under public/ is a legitimate thing to ship).
467
+ async function collectFiles(dir: string, base = dir): Promise<string[]> {
468
+ const out: string[] = [];
469
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
470
+ const abs = join(dir, entry.name);
471
+ if (entry.isDirectory()) out.push(...(await collectFiles(abs, base)));
472
+ else if (entry.isFile()) out.push(relative(base, abs).split(sep).join("/"));
473
+ }
474
+ return out;
475
+ }
476
+
463
477
  export async function juneBuild(
464
478
  appRoot: string,
465
479
  options: { outDir?: string; external?: string[] } = {},
@@ -780,6 +794,41 @@ ${adapterEntry.wrap("pipeline")}
780
794
  const worker = createWorker(manifest);
781
795
  let hasAssets = false;
782
796
 
797
+ // ---- public/ → assets/ : verbatim static files (favicon, images, fonts) ----
798
+ // Copied here, BEFORE the framework's hashed assets are written below, so a
799
+ // stray public/_june/* can never overwrite the real client bundle / CSS (it is
800
+ // skipped outright). Passthrough only — no hashing/optimization. `publicFiles`
801
+ // is the relative-path list adapters need to place these on their static tier.
802
+ const publicDir = join(appRoot, "public");
803
+ const publicFiles: string[] = [];
804
+ // Only a REAL directory: a symlinked public/ (e.g. `public -> ..`) would copy
805
+ // files from OUTSIDE the app root into the deploy output. collectFiles already
806
+ // drops symlinked entries UNDER public/ (Dirent.isFile()/isDirectory()); this
807
+ // guards the root itself. lstatSync doesn't follow the symlink, so a symlinked
808
+ // public/ has isDirectory() === false.
809
+ let publicIsDir = false;
810
+ try {
811
+ publicIsDir = lstatSync(publicDir).isDirectory();
812
+ } catch {
813
+ /* no public/ */
814
+ }
815
+ if (existsSync(publicDir) && !publicIsDir) {
816
+ console.warn(`[june] public/ is not a real directory (symlink?) — skipped`);
817
+ }
818
+ if (publicIsDir) {
819
+ for (const rel of await collectFiles(publicDir)) {
820
+ if (rel.split("/")[0] === RESERVED_PREFIX) {
821
+ console.warn(`[june] public/${rel} ignored — ${RESERVED_PREFIX}/ is reserved for framework assets`);
822
+ continue;
823
+ }
824
+ const dest = join(assetsDir, rel);
825
+ await mkdir(dirname(dest), { recursive: true });
826
+ await copyFile(join(publicDir, ...rel.split("/")), dest);
827
+ publicFiles.push(rel);
828
+ }
829
+ if (publicFiles.length) hasAssets = true;
830
+ }
831
+
783
832
  // static() target: prerender EVERY route (not just opted-in ones) + enumerate
784
833
  // dynamic routes via their staticPaths, and write <stem>/index.html so clean URLs
785
834
  // resolve on a dumb file host with no rewrite server. Other targets keep the
@@ -898,7 +947,7 @@ ${adapterEntry.wrap("pipeline")}
898
947
  resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
899
948
  ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
900
949
  : {};
901
- await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName });
950
+ await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles });
902
951
  if (plan.db) {
903
952
  console.log(
904
953
  ` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,
package/src/index.ts CHANGED
@@ -27,6 +27,10 @@ export {
27
27
  // raw queries when Juno is installed (Juno registers the tagger via @junejs/db) —
28
28
  // so the framework never imports Juno, yet `db` is the tagging one in a Juno app.
29
29
  export { db, kv, blob } from "@junejs/db";
30
+ // The app-services seam (see AgentDurableObject's `services` / RequestScope.services):
31
+ // read the isolate's app-defined bag from any tool or loader. The app builds it from
32
+ // env at the isolate entry and types it here — `currentServices<AppServices>()`.
33
+ export { currentServices } from "@junejs/db";
30
34
  export { loadJuneConfig } from "./config-loader";
31
35
  export {
32
36
  migrate,
@@ -0,0 +1,84 @@
1
+ // Static-file serving for the app-root `public/` directory — the ONE place that
2
+ // maps a URL path to a verbatim file. Shared by the dev host (app.ts, node:fs)
3
+ // and the Deno adapter runtime (withDenoAssets, Deno.readFile). Cloudflare and
4
+ // Vercel serve public files through their own platform layer (the ASSETS binding
5
+ // / the `filesystem` route handle) and don't call in here.
6
+ //
7
+ // `public/` is PASSTHROUGH: no content-hashing, no format conversion, no
8
+ // optimization — that stays the future image service's job. This module is pure
9
+ // (no node built-ins) so it bundles cleanly into the worker (Deno/edge) too.
10
+
11
+ // The framework owns this top-level URL segment (hashed client bundle + CSS live
12
+ // under it). A user file must never be served from `public/_june/...`, so the
13
+ // dev lookup and the build copy both skip it. NOTE: the Deno runtime asset server
14
+ // legitimately serves `/_june/*` (the real framework assets), so it does NOT
15
+ // apply this reservation — safeRelativePath() below is traversal-only.
16
+ export const RESERVED_PREFIX = "_june";
17
+
18
+ // Extension → MIME. Deliberately small and explicit (no `mime` dependency): the
19
+ // set of things people actually drop in public/. Unknown → octet-stream.
20
+ const CONTENT_TYPES: Record<string, string> = {
21
+ // text / markup
22
+ html: "text/html; charset=utf-8",
23
+ css: "text/css; charset=utf-8",
24
+ js: "text/javascript; charset=utf-8",
25
+ mjs: "text/javascript; charset=utf-8",
26
+ json: "application/json; charset=utf-8",
27
+ map: "application/json; charset=utf-8",
28
+ xml: "application/xml; charset=utf-8",
29
+ txt: "text/plain; charset=utf-8",
30
+ md: "text/markdown; charset=utf-8",
31
+ csv: "text/csv; charset=utf-8",
32
+ webmanifest: "application/manifest+json",
33
+ // images
34
+ svg: "image/svg+xml",
35
+ png: "image/png",
36
+ jpg: "image/jpeg",
37
+ jpeg: "image/jpeg",
38
+ gif: "image/gif",
39
+ webp: "image/webp",
40
+ avif: "image/avif",
41
+ ico: "image/x-icon",
42
+ bmp: "image/bmp",
43
+ // fonts
44
+ woff2: "font/woff2",
45
+ woff: "font/woff",
46
+ ttf: "font/ttf",
47
+ otf: "font/otf",
48
+ // media / binary
49
+ mp4: "video/mp4",
50
+ webm: "video/webm",
51
+ mp3: "audio/mpeg",
52
+ ogg: "audio/ogg",
53
+ wav: "audio/wav",
54
+ wasm: "application/wasm",
55
+ pdf: "application/pdf",
56
+ zip: "application/zip",
57
+ };
58
+
59
+ export function contentTypeFor(pathname: string): string {
60
+ const dot = pathname.lastIndexOf(".");
61
+ if (dot === -1) return "application/octet-stream";
62
+ return CONTENT_TYPES[pathname.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
63
+ }
64
+
65
+ // Clean a URL pathname into a safe relative path (forward-slash separated) under
66
+ // a static root, or null if it is malformed, empty, or escapes the root. Callers
67
+ // join the result to their own root (node:path.join for dev, a URL for Deno).
68
+ // Traversal guard ONLY — the reserved `_june` segment is enforced by callers, not
69
+ // here, because the Deno asset server must be able to serve it.
70
+ export function safeRelativePath(pathname: string): string | null {
71
+ let decoded: string;
72
+ try {
73
+ decoded = decodeURIComponent(pathname);
74
+ } catch {
75
+ return null; // malformed %-encoding
76
+ }
77
+ // A backslash or NUL in a URL path is never a legitimate static file request;
78
+ // rejecting them closes the Windows separator / null-byte traversal tricks.
79
+ if (decoded.includes("\0") || decoded.includes("\\")) return null;
80
+ const segments = decoded.split("/").filter((s) => s !== "" && s !== ".");
81
+ if (segments.length === 0) return null; // "/" is a page, not a file
82
+ if (segments.some((s) => s === "..")) return null; // no directory climbing
83
+ return segments.join("/");
84
+ }
package/src/worker.ts CHANGED
@@ -19,6 +19,7 @@ import type { Resources } from "@junejs/core/resources";
19
19
 
20
20
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
21
21
  import { durableAgentSurface, type DurableObjectNamespace } from "./agent-durable";
22
+ import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
22
23
 
23
24
  export type WorkerManifest = {
24
25
  // Static paths → route definitions ("/", "/users", ...).
@@ -297,39 +298,55 @@ export function withAssets(
297
298
  // referenced only inside the body, so non-Deno bundles tree-shake this out.
298
299
  declare const Deno: {
299
300
  readFile(path: string | URL): Promise<Uint8Array>;
300
- };
301
-
302
- const ASSET_CONTENT_TYPES: Record<string, string> = {
303
- css: "text/css; charset=utf-8",
304
- js: "text/javascript; charset=utf-8",
305
- json: "application/json; charset=utf-8",
306
- map: "application/json; charset=utf-8",
307
- svg: "image/svg+xml",
308
- png: "image/png",
309
- jpg: "image/jpeg",
310
- jpeg: "image/jpeg",
311
- webp: "image/webp",
312
- avif: "image/avif",
313
- ico: "image/x-icon",
314
- woff2: "font/woff2",
315
- woff: "font/woff",
301
+ stat(path: string | URL): Promise<{ isFile: boolean; size: number }>;
316
302
  };
317
303
 
318
304
  export function withDenoAssets(pipeline: FetchPipeline): (request: Request) => Promise<Response> {
319
305
  return async (request) => {
320
- const url = new URL(request.url);
321
- if (request.method === "GET" && url.pathname.startsWith("/_june/")) {
322
- try {
323
- const file = await Deno.readFile(new URL(`./assets${url.pathname}`, import.meta.url));
324
- const ext = url.pathname.split(".").pop()?.toLowerCase() ?? "";
325
- return new Response(file as BodyInit, {
326
- headers: {
327
- "content-type": ASSET_CONTENT_TYPES[ext] ?? "application/octet-stream",
328
- "cache-control": "public, max-age=31536000, immutable",
329
- },
330
- });
331
- } catch {
332
- /* not on disk → fall through (the pipeline 404s) */
306
+ const method = request.method;
307
+ if (method === "GET" || method === "HEAD") {
308
+ const url = new URL(request.url);
309
+ // Probe the co-located assets/ dir for ANY non-root path (safeRelativePath
310
+ // rejects "/"): /_june/* (hashed framework assets) plus public/ files copied
311
+ // there at build — INCLUDING extensionless ones (e.g. .well-known/
312
+ // apple-app-site-association), so "public/ served verbatim" holds on Deno
313
+ // like every other target. A miss (ENOENT) falls through to the pipeline.
314
+ // The reserved /_june/ prefix is served here too — safeRelativePath is
315
+ // traversal-only. (An extra ENOENT syscall per page nav is dwarfed by SSR.)
316
+ {
317
+ const rel = safeRelativePath(url.pathname);
318
+ if (rel !== null) {
319
+ try {
320
+ // Build the file URL from rel by encoding EACH segment — interpolating
321
+ // the raw (decoded) rel would let a literal `?`/`#` in a filename start
322
+ // a query/fragment and read the wrong file.
323
+ const assetUrl = new URL(
324
+ `./assets/${rel.split("/").map(encodeURIComponent).join("/")}`,
325
+ import.meta.url,
326
+ );
327
+ // Derive both from the CANONICAL rel, not the raw url.pathname: a
328
+ // percent-encoded separator (e.g. /_june%2Fapp.js) decodes to the same
329
+ // asset but would miss a startsWith("/_june/") test on the raw path.
330
+ const immutable = rel.startsWith(`${RESERVED_PREFIX}/`); // hashed → immutable
331
+ const headers: Record<string, string> = {
332
+ "content-type": contentTypeFor(rel),
333
+ "cache-control": immutable
334
+ ? "public, max-age=31536000, immutable"
335
+ : "public, max-age=0, must-revalidate",
336
+ };
337
+ // HEAD: stat for size only — don't read a (possibly large) body just to
338
+ // discard it. A non-file (directory) stat falls through to the pipeline.
339
+ if (method === "HEAD") {
340
+ const info = await Deno.stat(assetUrl);
341
+ if (!info.isFile) throw new Error("not a file");
342
+ return new Response(null, { headers: { ...headers, "content-length": String(info.size) } });
343
+ }
344
+ const file = await Deno.readFile(assetUrl);
345
+ return new Response(file as BodyInit, { headers });
346
+ } catch {
347
+ /* not on disk → fall through (the pipeline 404s) */
348
+ }
349
+ }
333
350
  }
334
351
  }
335
352
  // No env arg: Deno has no platform bindings (unlike D1/KV on workers); env-