@junejs/server 0.1.0-dev.4 → 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.
Files changed (105) hide show
  1. package/dist/adapter.d.ts +56 -0
  2. package/dist/adapter.js +211 -0
  3. package/dist/agent-discover.d.ts +5 -0
  4. package/dist/agent-discover.js +75 -0
  5. package/dist/agent-durable.d.ts +72 -0
  6. package/dist/agent-durable.js +141 -0
  7. package/dist/agent-native.d.ts +40 -0
  8. package/dist/agent-native.js +179 -0
  9. package/dist/app.d.ts +15 -0
  10. package/dist/app.js +252 -0
  11. package/dist/blob.d.ts +23 -0
  12. package/dist/blob.js +78 -0
  13. package/dist/build.d.ts +40 -0
  14. package/dist/build.js +582 -0
  15. package/dist/client-bundle.d.ts +15 -0
  16. package/dist/client-bundle.js +91 -0
  17. package/dist/config-loader.d.ts +9 -0
  18. package/dist/config-loader.js +25 -0
  19. package/dist/content.d.ts +39 -0
  20. package/dist/content.js +201 -0
  21. package/dist/css-modules-ambient.d.ts +10 -0
  22. package/dist/css-modules.d.ts +24 -0
  23. package/dist/css-modules.js +127 -0
  24. package/dist/css.d.ts +14 -0
  25. package/dist/css.js +114 -0
  26. package/dist/d1-BkdUg9kg.d.ts +24 -0
  27. package/dist/d1-migrate.d.ts +31 -0
  28. package/dist/d1-migrate.js +156 -0
  29. package/dist/d1.d.ts +2 -0
  30. package/dist/d1.js +32 -0
  31. package/dist/db.d.ts +11 -0
  32. package/dist/db.js +20 -0
  33. package/dist/deploy.d.ts +25 -0
  34. package/dist/deploy.js +158 -0
  35. package/dist/dev-reload.d.ts +5 -0
  36. package/dist/dev-reload.js +123 -0
  37. package/dist/dev.d.ts +13 -0
  38. package/dist/dev.js +55 -0
  39. package/dist/host.d.ts +25 -0
  40. package/dist/host.js +107 -0
  41. package/dist/index.d.ts +26 -0
  42. package/dist/index.js +26 -0
  43. package/dist/instrumentation.d.ts +4 -0
  44. package/dist/instrumentation.js +15 -0
  45. package/dist/island-registry.d.ts +8 -0
  46. package/dist/island-registry.js +117 -0
  47. package/dist/kv.d.ts +8 -0
  48. package/dist/kv.js +35 -0
  49. package/dist/migrate-BbWt-PuB.d.ts +32 -0
  50. package/dist/migrate.d.ts +2 -0
  51. package/dist/migrate.js +115 -0
  52. package/dist/mysql-driver-BsrWVqbp.d.ts +12 -0
  53. package/dist/mysql-driver.d.ts +2 -0
  54. package/dist/mysql-driver.js +52 -0
  55. package/dist/negotiate.d.ts +11 -0
  56. package/dist/negotiate.js +39 -0
  57. package/dist/pipeline-B2V6tKfO.d.ts +49 -0
  58. package/dist/pipeline.d.ts +2 -0
  59. package/dist/pipeline.js +235 -0
  60. package/dist/postgres-driver-DkkBFikA.d.ts +15 -0
  61. package/dist/postgres-driver.d.ts +2 -0
  62. package/dist/postgres-driver.js +50 -0
  63. package/dist/resources.d.ts +15 -0
  64. package/dist/resources.js +31 -0
  65. package/dist/router.d.ts +32 -0
  66. package/dist/router.js +183 -0
  67. package/dist/rsc-build.d.ts +17 -0
  68. package/dist/rsc-build.js +68 -0
  69. package/dist/rsc-bundle.d.ts +8 -0
  70. package/dist/rsc-bundle.js +39 -0
  71. package/dist/rsc-manifest.d.ts +8 -0
  72. package/dist/rsc-manifest.js +67 -0
  73. package/dist/rsc-runtime/flight-render-routes.d.ts +5 -0
  74. package/dist/rsc-runtime/flight-render-routes.js +26 -0
  75. package/dist/rsc-runtime/flight-render.d.ts +4 -0
  76. package/dist/rsc-runtime/flight-render.js +21 -0
  77. package/dist/rsc-runtime/flight-to-html.d.ts +4 -0
  78. package/dist/rsc-runtime/flight-to-html.js +32 -0
  79. package/dist/rsc-runtime/rsc-worker.d.ts +6 -0
  80. package/dist/rsc-runtime/rsc-worker.js +46 -0
  81. package/dist/rsc-runtime/rsdw-shims.d.ts +42 -0
  82. package/dist/segment.d.ts +14 -0
  83. package/dist/segment.js +33 -0
  84. package/dist/sqlite-driver.d.ts +25 -0
  85. package/dist/sqlite-driver.js +102 -0
  86. package/dist/static-files.d.ts +6 -0
  87. package/dist/static-files.js +56 -0
  88. package/dist/tsconfig-jsx.d.ts +15 -0
  89. package/dist/tsconfig-jsx.js +35 -0
  90. package/dist/turso-driver-C1wEigMZ.d.ts +22 -0
  91. package/dist/turso-driver.d.ts +2 -0
  92. package/dist/turso-driver.js +68 -0
  93. package/dist/worker.d.ts +55 -0
  94. package/dist/worker.js +211 -0
  95. package/package.json +115 -23
  96. package/src/adapter.ts +20 -7
  97. package/src/agent-discover.ts +5 -2
  98. package/src/agent-durable.ts +108 -4
  99. package/src/app.ts +74 -2
  100. package/src/build.ts +52 -3
  101. package/src/client-bundle.ts +3 -1
  102. package/src/index.ts +4 -0
  103. package/src/rsc-bundle.ts +3 -2
  104. package/src/static-files.ts +84 -0
  105. package/src/worker.ts +46 -29
@@ -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})`,
@@ -68,7 +68,9 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode, ma
68
68
  // CONFIGURATION_FIELD_CONFLICT, value-independent). See tsconfig-jsx.ts.
69
69
  jsx: await jsxTransform(cwd),
70
70
  },
71
- resolve: { conditionNames: ["browser", "import", "default"] },
71
+ // "source" first so @junejs/* resolves to its src/*.ts (never a pre-built
72
+ // dist) — the client graph bundles June's own runtime (e.g. islands-client).
73
+ resolve: { conditionNames: ["source", "browser", "import", "default"] },
72
74
  onLog(level, log, handler) {
73
75
  if (isExpectedClientLog(log)) return;
74
76
  handler(level, log);
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,
package/src/rsc-bundle.ts CHANGED
@@ -19,8 +19,9 @@ import { rscClientReferencesPlugin } from "./rsc-manifest";
19
19
  export type RscGraph = "server" | "ssr";
20
20
 
21
21
  // edge-first conditions shared by both graphs; the server graph prepends
22
- // "react-server" so React resolves to the server build.
23
- const EDGE_CONDITIONS = ["workerd", "edge-light", "import", "default"];
22
+ // "react-server" so React resolves to the server build. "source" leads so
23
+ // @junejs/* resolves to its src/*.ts (never a pre-built dist).
24
+ const EDGE_CONDITIONS = ["source", "workerd", "edge-light", "import", "default"];
24
25
 
25
26
  // Generic-entry virtuals the build resolves to real app modules (so one runtime
26
27
  // entry serves any app): "june:app" (the app root), "june:rsc-client" (the
@@ -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-