@junejs/server 0.1.0-dev.5 → 0.1.0

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.
@@ -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
@@ -166,6 +182,15 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
166
182
 
167
183
  const resources = memoizeResources(config.resources);
168
184
 
185
+ // Build the app-services bag once from process.env (dev's env; stable across the
186
+ // process) and hand the pipeline a memoized provider — the dev twin of the worker
187
+ // binding services from its per-isolate env. The `{ v }` box memoizes even a
188
+ // null/undefined result. Absent config → no provider → currentServices() undefined.
189
+ let servicesCache: { v: unknown } | undefined;
190
+ const services = config.services
191
+ ? () => (servicesCache ??= { v: config.services!.make(process.env) }).v
192
+ : undefined;
193
+
169
194
  // Boot the opt-in Tier-3 data layer (e.g. `dataLayer: junoDataLayer()`) once:
170
195
  // its install() wires the ambient `db` (Juno registers its SQL tagger). Explicit
171
196
  // and config-declared — no import-time side-effect, and the framework still never
@@ -229,6 +254,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
229
254
  routeList: routePaths,
230
255
  earlyHints: config.earlyHints,
231
256
  resources,
257
+ services,
232
258
  notFoundComponent,
233
259
  resolve: async (pathname) => {
234
260
  const match =
@@ -321,7 +347,63 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
321
347
  );
322
348
  }
323
349
  }
324
- return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
350
+ // Static files under app-root public/, served verbatim. Mirrors the prod
351
+ // asset layer (Cloudflare ASSETS / Vercel filesystem / Deno withDenoAssets
352
+ // all answer before the pipeline), so a public/ file shadows a same-path
353
+ // route in dev exactly as it does when deployed. Passthrough — no hashing
354
+ // or optimization. GET/HEAD only; a miss falls through to the render core.
355
+ const toPipeline = () => runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
356
+ if (publicRoot !== null && (request.method === "GET" || request.method === "HEAD")) {
357
+ const rel = safeRelativePath(new URL(request.url).pathname);
358
+ if (rel !== null && rel.split("/")[0] !== RESERVED_PREFIX) {
359
+ const segments = rel.split("/");
360
+ const file = join(publicRoot, ...segments); // anchor on the REAL public root
361
+ // lstatSync (NOT statSync) gets existence+type+size in one syscall
362
+ // without following a leaf symlink. ENOENT / EACCES → treat as a miss.
363
+ let stat: Stats | undefined;
364
+ try {
365
+ stat = lstatSync(file);
366
+ } catch {
367
+ /* not a readable file → fall through */
368
+ }
369
+ // Serve only a real file whose resolved path is STILL inside publicRoot:
370
+ // realpathSync(file) === file holds iff no path component (leaf OR an
371
+ // intermediate dir) is a symlink. That blocks symlink escapes AND matches
372
+ // the build, which drops every symlinked entry under public/.
373
+ let real: string | null = null;
374
+ if (stat?.isFile()) {
375
+ try {
376
+ real = realpathSync(file);
377
+ } catch {
378
+ /* race: gone between stat and realpath → miss */
379
+ }
380
+ }
381
+ if (real === file) {
382
+ const headers = {
383
+ "content-type": contentTypeFor(rel),
384
+ // Not content-hashed → must revalidate, never immutable.
385
+ "cache-control": "public, max-age=0, must-revalidate",
386
+ };
387
+ // HEAD: existence + size already proven — answer without reading the
388
+ // body (a large asset would otherwise be read and discarded).
389
+ if (request.method === "HEAD") {
390
+ return Promise.resolve(
391
+ new Response(null, { headers: { ...headers, "content-length": String(stat!.size) } }),
392
+ );
393
+ }
394
+ // A file deleted/locked between stat and read (dev editors do atomic
395
+ // save = write-temp + rename) must not crash the request — fall
396
+ // through to the pipeline on a read error, same as any other miss.
397
+ // readFile returns a Buffer (a Uint8Array) — pass it straight through;
398
+ // re-wrapping in new Uint8Array(buf) would copy every byte.
399
+ return readFile(file).then(
400
+ (buf) => new Response(buf, { headers }),
401
+ () => toPipeline(),
402
+ );
403
+ }
404
+ }
405
+ }
406
+ return toPipeline();
325
407
  },
326
408
  async warmup() {
327
409
  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[] } = {},
@@ -692,6 +706,23 @@ export async function juneBuild(
692
706
  }
693
707
  const dataLayerBoot = dataLayerModule ? "\ninstallDataLayer();\n" : "";
694
708
 
709
+ // App services: import the app's `(env) => services` factory (the named `services`
710
+ // export of the declared module) into the entry and hand it to createWorker, which
711
+ // binds it from the worker env per request — the prod twin of app.ts building it from
712
+ // process.env. Like resources, the module (not the config) is imported, so the config's
713
+ // host-only bits never enter the worker graph. Emitted only when declared.
714
+ //
715
+ // The specifier is rebased like a route import: an app-relative path (`./app/services.ts`)
716
+ // is resolved against appRoot then made relative to the entry's dir (.june), since the
717
+ // generated entry lives there; a bare specifier (a shared package, like dataLayer.module)
718
+ // is used verbatim.
719
+ const servicesModule = fullConfig.services?.module;
720
+ if (servicesModule) {
721
+ const spec = /^[./]/.test(servicesModule) ? importPath(genDir, resolve(appRoot, servicesModule)) : servicesModule;
722
+ imports.push(`import { services as __appServices } from ${JSON.stringify(spec)};`);
723
+ }
724
+ const servicesField = servicesModule ? `\n services: __appServices,` : "";
725
+
695
726
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
696
727
  ${adapterEntry.imports.join("\n")}
697
728
  ${imports.join("\n")}
@@ -711,7 +742,7 @@ ${loadings.join("\n")}
711
742
  },
712
743
  document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
713
744
  agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
714
- earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
745
+ earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}${servicesField}
715
746
  });
716
747
 
717
748
  ${adapterEntry.wrap("pipeline")}
@@ -780,6 +811,41 @@ ${adapterEntry.wrap("pipeline")}
780
811
  const worker = createWorker(manifest);
781
812
  let hasAssets = false;
782
813
 
814
+ // ---- public/ → assets/ : verbatim static files (favicon, images, fonts) ----
815
+ // Copied here, BEFORE the framework's hashed assets are written below, so a
816
+ // stray public/_june/* can never overwrite the real client bundle / CSS (it is
817
+ // skipped outright). Passthrough only — no hashing/optimization. `publicFiles`
818
+ // is the relative-path list adapters need to place these on their static tier.
819
+ const publicDir = join(appRoot, "public");
820
+ const publicFiles: string[] = [];
821
+ // Only a REAL directory: a symlinked public/ (e.g. `public -> ..`) would copy
822
+ // files from OUTSIDE the app root into the deploy output. collectFiles already
823
+ // drops symlinked entries UNDER public/ (Dirent.isFile()/isDirectory()); this
824
+ // guards the root itself. lstatSync doesn't follow the symlink, so a symlinked
825
+ // public/ has isDirectory() === false.
826
+ let publicIsDir = false;
827
+ try {
828
+ publicIsDir = lstatSync(publicDir).isDirectory();
829
+ } catch {
830
+ /* no public/ */
831
+ }
832
+ if (existsSync(publicDir) && !publicIsDir) {
833
+ console.warn(`[june] public/ is not a real directory (symlink?) — skipped`);
834
+ }
835
+ if (publicIsDir) {
836
+ for (const rel of await collectFiles(publicDir)) {
837
+ if (rel.split("/")[0] === RESERVED_PREFIX) {
838
+ console.warn(`[june] public/${rel} ignored — ${RESERVED_PREFIX}/ is reserved for framework assets`);
839
+ continue;
840
+ }
841
+ const dest = join(assetsDir, rel);
842
+ await mkdir(dirname(dest), { recursive: true });
843
+ await copyFile(join(publicDir, ...rel.split("/")), dest);
844
+ publicFiles.push(rel);
845
+ }
846
+ if (publicFiles.length) hasAssets = true;
847
+ }
848
+
783
849
  // static() target: prerender EVERY route (not just opted-in ones) + enumerate
784
850
  // dynamic routes via their staticPaths, and write <stem>/index.html so clean URLs
785
851
  // resolve on a dumb file host with no rewrite server. Other targets keep the
@@ -898,7 +964,7 @@ ${adapterEntry.wrap("pipeline")}
898
964
  resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
899
965
  ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
900
966
  : {};
901
- await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName });
967
+ await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles });
902
968
  if (plan.db) {
903
969
  console.log(
904
970
  ` ↳ 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,13 @@ 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";
34
+ // Declare that bag for the Worker side in june.config.ts (`services: defineServices(...)`)
35
+ // — the twin of the DO's constructor injection, seeded into loaders/views/actions.
36
+ export { defineServices, type ServicesConfig } from "@junejs/core/config";
30
37
  export { loadJuneConfig } from "./config-loader";
31
38
  export {
32
39
  migrate,
package/src/pipeline.ts CHANGED
@@ -121,6 +121,11 @@ export type PipelineConfig = {
121
121
  // Opened data resources (db/kv/blob) injected onto ctx before load(). A
122
122
  // provider so opening is lazy/memoized; absent → no resources on ctx.
123
123
  resources?: () => Promise<Resources> | Resources;
124
+ // The app-defined services bag, built from the isolate's env (config `services`).
125
+ // A provider like `resources` (lazy/memoized); seeded into the request scope so
126
+ // `currentServices()` resolves in loaders/views/actions. Absent → currentServices()
127
+ // is undefined. This is the Worker-side twin of a Durable Object seeding services.
128
+ services?: () => Promise<unknown> | unknown;
124
129
  earlyHints?: string[];
125
130
  htmlCacheControl?: string;
126
131
  notFoundComponent?: React.ComponentType<{ pathname: string }>;
@@ -578,7 +583,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
578
583
  // async-context provider on first request (no static node:* import).
579
584
  await ensureScope();
580
585
  const resources = cfg.resources ? await cfg.resources() : {};
581
- return runInScope({ resources }, () => handleRequest(request));
586
+ const services = cfg.services ? await cfg.services() : undefined;
587
+ return runInScope({ resources, services }, () => handleRequest(request));
582
588
  },
583
589
  };
584
590
  }
@@ -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", ...).
@@ -58,6 +59,11 @@ export type WorkerManifest = {
58
59
  // bindings come from env, so the generated entry passes an ENV-AWARE provider
59
60
  // (bindWorkerResources) — the worker threads its env through on each fetch.
60
61
  resources?: (env?: unknown) => Promise<Resources> | Resources;
62
+ // App-defined services (config `services`), built from the worker's env. The build
63
+ // imports the app's `(env) => services` factory into the entry and sets it here; the
64
+ // worker threads its env through and createWorker memoizes it per isolate. Seeded
65
+ // into the request scope so `currentServices()` resolves in loaders/views/actions.
66
+ services?: (env?: unknown) => unknown;
61
67
  };
62
68
 
63
69
  type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
@@ -132,6 +138,12 @@ export function createWorker(
132
138
  let currentEnv: unknown;
133
139
  const provider = manifest.resources;
134
140
 
141
+ // App services: the build sets a raw `(env) => services` factory here; memoize it per
142
+ // isolate (env is stable per isolate, like the resources provider). The `{ v }` box
143
+ // caches even a null/undefined result. Absent → currentServices() stays undefined.
144
+ const servicesProvider = manifest.services;
145
+ let servicesCache: { v: unknown } | undefined;
146
+
135
147
  // Durable agent (edge): route the chat endpoint to the per-session DO bound at
136
148
  // env.AGENT. Built only when the app has an agent (manifest.agentName) and the
137
149
  // runtime is enabled; the surface is inert (null → fall through) until env.AGENT
@@ -158,6 +170,7 @@ export function createWorker(
158
170
  notFoundComponent: manifest.notFound,
159
171
  extra: manifest.extra,
160
172
  resources: provider ? () => provider(currentEnv) : undefined,
173
+ services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : undefined,
161
174
  resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
162
175
  const staticDef = manifest.routes[pathname];
163
176
  if (staticDef) {
@@ -297,39 +310,55 @@ export function withAssets(
297
310
  // referenced only inside the body, so non-Deno bundles tree-shake this out.
298
311
  declare const Deno: {
299
312
  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",
313
+ stat(path: string | URL): Promise<{ isFile: boolean; size: number }>;
316
314
  };
317
315
 
318
316
  export function withDenoAssets(pipeline: FetchPipeline): (request: Request) => Promise<Response> {
319
317
  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) */
318
+ const method = request.method;
319
+ if (method === "GET" || method === "HEAD") {
320
+ const url = new URL(request.url);
321
+ // Probe the co-located assets/ dir for ANY non-root path (safeRelativePath
322
+ // rejects "/"): /_june/* (hashed framework assets) plus public/ files copied
323
+ // there at build — INCLUDING extensionless ones (e.g. .well-known/
324
+ // apple-app-site-association), so "public/ served verbatim" holds on Deno
325
+ // like every other target. A miss (ENOENT) falls through to the pipeline.
326
+ // The reserved /_june/ prefix is served here too — safeRelativePath is
327
+ // traversal-only. (An extra ENOENT syscall per page nav is dwarfed by SSR.)
328
+ {
329
+ const rel = safeRelativePath(url.pathname);
330
+ if (rel !== null) {
331
+ try {
332
+ // Build the file URL from rel by encoding EACH segment — interpolating
333
+ // the raw (decoded) rel would let a literal `?`/`#` in a filename start
334
+ // a query/fragment and read the wrong file.
335
+ const assetUrl = new URL(
336
+ `./assets/${rel.split("/").map(encodeURIComponent).join("/")}`,
337
+ import.meta.url,
338
+ );
339
+ // Derive both from the CANONICAL rel, not the raw url.pathname: a
340
+ // percent-encoded separator (e.g. /_june%2Fapp.js) decodes to the same
341
+ // asset but would miss a startsWith("/_june/") test on the raw path.
342
+ const immutable = rel.startsWith(`${RESERVED_PREFIX}/`); // hashed → immutable
343
+ const headers: Record<string, string> = {
344
+ "content-type": contentTypeFor(rel),
345
+ "cache-control": immutable
346
+ ? "public, max-age=31536000, immutable"
347
+ : "public, max-age=0, must-revalidate",
348
+ };
349
+ // HEAD: stat for size only — don't read a (possibly large) body just to
350
+ // discard it. A non-file (directory) stat falls through to the pipeline.
351
+ if (method === "HEAD") {
352
+ const info = await Deno.stat(assetUrl);
353
+ if (!info.isFile) throw new Error("not a file");
354
+ return new Response(null, { headers: { ...headers, "content-length": String(info.size) } });
355
+ }
356
+ const file = await Deno.readFile(assetUrl);
357
+ return new Response(file as BodyInit, { headers });
358
+ } catch {
359
+ /* not on disk → fall through (the pipeline 404s) */
360
+ }
361
+ }
333
362
  }
334
363
  }
335
364
  // No env arg: Deno has no platform bindings (unlike D1/KV on workers); env-