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