@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.
- package/dist/adapter.d.ts +1 -0
- package/dist/adapter.js +9 -2
- package/dist/agent-discover.js +2 -2
- package/dist/agent-durable.d.ts +13 -1
- package/dist/agent-durable.js +38 -3
- package/dist/app.js +39 -2
- package/dist/build.js +41 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/{pipeline-B2V6tKfO.d.ts → pipeline-CCp9idQ4.d.ts} +1 -0
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +5 -2
- package/dist/static-files.d.ts +6 -0
- package/dist/static-files.js +56 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +29 -24
- package/package.json +3 -3
- package/src/adapter.ts +16 -3
- package/src/agent-discover.ts +5 -2
- package/src/agent-durable.ts +108 -4
- package/src/app.ts +84 -2
- package/src/build.ts +70 -4
- package/src/index.ts +7 -0
- package/src/pipeline.ts +7 -1
- package/src/static-files.ts +84 -0
- package/src/worker.ts +58 -29
package/src/agent-durable.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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-
|