@junejs/server 0.0.8 → 0.0.9
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/package.json +2 -2
- package/src/app.ts +5 -4
- package/src/build.ts +43 -16
- package/src/negotiate.ts +0 -2
- package/src/pipeline.ts +28 -20
- package/src/worker.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@junejs/core": "0.0.
|
|
32
|
+
"@junejs/core": "0.0.9",
|
|
33
33
|
"marked": "^18.0.5",
|
|
34
34
|
"rolldown": "^1.1.0"
|
|
35
35
|
},
|
package/src/app.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname, resolve } from "node:path";
|
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import type { ComponentType } from "react";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { routeFromModule } from "@junejs/core/route";
|
|
17
17
|
import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/core/config";
|
|
18
18
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
19
19
|
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
@@ -117,9 +117,10 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
117
117
|
resolve: async (pathname): Promise<Resolved | null> => {
|
|
118
118
|
const match = await matchRouteTree(appDir, pathname, { pageConvention: true });
|
|
119
119
|
if (!match) return null;
|
|
120
|
-
const mod =
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const mod = await import(pathToFileURL(match.file).href);
|
|
121
|
+
const def = routeFromModule(mod);
|
|
122
|
+
if (!def) return null;
|
|
123
|
+
return { def, params: match.params, chain: await loadChain(match.segments) };
|
|
123
124
|
},
|
|
124
125
|
});
|
|
125
126
|
}
|
package/src/build.ts
CHANGED
|
@@ -24,7 +24,8 @@ import { pathToFileURL } from "node:url";
|
|
|
24
24
|
|
|
25
25
|
import { loadJuneConfig } from "./config-loader";
|
|
26
26
|
import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
|
|
27
|
-
import {
|
|
27
|
+
import { buildLinkHeader } from "@junejs/core/discovery";
|
|
28
|
+
import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
|
|
28
29
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
29
30
|
import { collection } from "./content";
|
|
30
31
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
@@ -171,14 +172,15 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
171
172
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
172
173
|
|
|
173
174
|
for (const r of scanned) {
|
|
174
|
-
const mod =
|
|
175
|
-
|
|
175
|
+
const mod = await import(pathToFileURL(r.file).href);
|
|
176
|
+
const def = routeFromModule(mod);
|
|
177
|
+
if (!def) continue;
|
|
176
178
|
const chain = await componentsFor(r.layouts);
|
|
177
179
|
if (r.dynamic) {
|
|
178
|
-
dynamicRoutes.push({ pattern: r.path, def
|
|
180
|
+
dynamicRoutes.push({ pattern: r.path, def });
|
|
179
181
|
layoutChains[r.path] = chain;
|
|
180
182
|
} else {
|
|
181
|
-
routes[r.path] =
|
|
183
|
+
routes[r.path] = def;
|
|
182
184
|
layoutChains[r.path] = chain;
|
|
183
185
|
}
|
|
184
186
|
}
|
|
@@ -224,7 +226,13 @@ export async function juneBuild(
|
|
|
224
226
|
const frozen = await freezeConfig(appRoot);
|
|
225
227
|
|
|
226
228
|
// ---- generated entry -----------------------------------------------------
|
|
227
|
-
|
|
229
|
+
// Routes are namespace-imported and adapted with routeFromModule, so the
|
|
230
|
+
// multi-export page shape (default view + named loader/json/md) and the legacy
|
|
231
|
+
// route({}) default export both work.
|
|
232
|
+
const imports: string[] = [
|
|
233
|
+
`import { createWorker } from "@junejs/server/worker";`,
|
|
234
|
+
`import { routeFromModule } from "@junejs/core/route";`,
|
|
235
|
+
];
|
|
228
236
|
const statics: string[] = [];
|
|
229
237
|
const dynamics: string[] = [];
|
|
230
238
|
const layoutIds = new Map<string, string>();
|
|
@@ -239,9 +247,9 @@ export async function juneBuild(
|
|
|
239
247
|
};
|
|
240
248
|
const chains: string[] = [];
|
|
241
249
|
routes.forEach((r, i) => {
|
|
242
|
-
imports.push(`import r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
243
|
-
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: r${i} },`);
|
|
244
|
-
else statics.push(` ${JSON.stringify(r.path)}: r${i},`);
|
|
250
|
+
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
251
|
+
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
252
|
+
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
245
253
|
chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
|
|
246
254
|
});
|
|
247
255
|
|
|
@@ -250,10 +258,17 @@ export async function juneBuild(
|
|
|
250
258
|
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
251
259
|
}
|
|
252
260
|
|
|
261
|
+
// The deployed worker runs first (wrangler assets.run_worker_first) and wraps
|
|
262
|
+
// the pipeline with withAssets — so prerendered pages, served from the ASSETS
|
|
263
|
+
// binding, still get the Link header + Accept:markdown negotiation the asset
|
|
264
|
+
// layer can't do. The Link header is frozen here from the same builder the
|
|
265
|
+
// pipeline uses, so the static and dynamic surfaces advertise identically.
|
|
266
|
+
const linkHeader = buildLinkHeader(frozen.agent);
|
|
253
267
|
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
|
268
|
+
import { withAssets } from "@junejs/server/worker";
|
|
254
269
|
${imports.join("\n")}
|
|
255
270
|
|
|
256
|
-
|
|
271
|
+
const pipeline = createWorker({
|
|
257
272
|
routes: {
|
|
258
273
|
${statics.join("\n")}
|
|
259
274
|
},
|
|
@@ -267,6 +282,8 @@ ${chains.join("\n")}
|
|
|
267
282
|
agent: ${JSON.stringify(frozen.agent)},
|
|
268
283
|
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
|
|
269
284
|
});
|
|
285
|
+
|
|
286
|
+
export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
|
|
270
287
|
`;
|
|
271
288
|
const entryFile = join(genDir, "worker-entry.tsx");
|
|
272
289
|
await writeFile(entryFile, entry);
|
|
@@ -310,11 +327,15 @@ ${chains.join("\n")}
|
|
|
310
327
|
const def = manifest.routes[r.path];
|
|
311
328
|
if (!def || !def.prerender) continue;
|
|
312
329
|
const stem = r.path === "/" ? "index" : r.path.slice(1);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
330
|
+
// The homepage's projection requests are `/.md` / `/.json` (negotiate strips
|
|
331
|
+
// the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
|
|
332
|
+
const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
|
|
333
|
+
const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
|
|
334
|
+
const targets: Array<[string, string]> = [
|
|
335
|
+
[r.path, `${stem}.html`],
|
|
336
|
+
[mdReq, `${stem}.md`],
|
|
337
|
+
];
|
|
338
|
+
if (typeof def.json === "function") targets.push([jsonReq, `${stem}.json`]);
|
|
318
339
|
for (const [reqPath, file] of targets) {
|
|
319
340
|
const res = await worker.fetch(new Request(`https://prerender.june${reqPath}`));
|
|
320
341
|
if (!res.ok) throw new Error(`prerender ${reqPath} → ${res.status}`);
|
|
@@ -352,7 +373,13 @@ ${chains.join("\n")}
|
|
|
352
373
|
main: "./worker.js",
|
|
353
374
|
compatibility_date: "2025-01-01",
|
|
354
375
|
compatibility_flags: ["nodejs_compat"],
|
|
355
|
-
|
|
376
|
+
// run_worker_first: the worker wraps asset serving (withAssets) so
|
|
377
|
+
// prerendered pages still get Link headers + Accept:markdown
|
|
378
|
+
// negotiation. The ASSETS binding lets the worker serve the static
|
|
379
|
+
// files itself.
|
|
380
|
+
...(hasAssets
|
|
381
|
+
? { assets: { directory: "./assets", binding: "ASSETS", run_worker_first: true } }
|
|
382
|
+
: {}),
|
|
356
383
|
// config deploy.domain → a Workers custom domain; without it the
|
|
357
384
|
// generated file regenerating on every build would silently drop a
|
|
358
385
|
// hand-attached domain.
|
package/src/negotiate.ts
CHANGED
|
@@ -10,12 +10,10 @@ import type { RenderTarget } from "@junejs/core/route";
|
|
|
10
10
|
|
|
11
11
|
const EXT_TARGET: Record<string, RenderTarget> = {
|
|
12
12
|
".json": "json",
|
|
13
|
-
".agent": "agent",
|
|
14
13
|
".md": "md",
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
const ACCEPT_TARGET: Array<[test: RegExp, target: RenderTarget]> = [
|
|
18
|
-
[/application\/vnd\.june-agent\+json/, "agent"],
|
|
19
17
|
[/text\/markdown/, "md"],
|
|
20
18
|
[/application\/json/, "json"],
|
|
21
19
|
];
|
package/src/pipeline.ts
CHANGED
|
@@ -18,14 +18,12 @@ import React from "react";
|
|
|
18
18
|
import { renderToReadableStream } from "react-dom/server";
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
-
resolveProjection,
|
|
22
21
|
type BrandedRoute,
|
|
23
22
|
type Metadata,
|
|
24
23
|
type RenderTarget,
|
|
25
24
|
type RouteContext,
|
|
26
25
|
} from "@junejs/core/route";
|
|
27
26
|
import { Document, type DocumentConfig } from "@junejs/core/document";
|
|
28
|
-
import { isResourceManifest } from "@junejs/core/agent";
|
|
29
27
|
import {
|
|
30
28
|
apiCatalog,
|
|
31
29
|
buildLinkHeader,
|
|
@@ -34,7 +32,7 @@ import {
|
|
|
34
32
|
robotsTxt,
|
|
35
33
|
sitemapXml,
|
|
36
34
|
} from "@junejs/core/discovery";
|
|
37
|
-
import { mcpHandler } from "@junejs/core/mcp";
|
|
35
|
+
import { mcpHandler, mcpTools } from "@junejs/core/mcp";
|
|
38
36
|
import type { AgentConfig } from "@junejs/core/config";
|
|
39
37
|
import type { Resources } from "@junejs/core/resources";
|
|
40
38
|
|
|
@@ -135,8 +133,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
135
133
|
(acc, L) => React.createElement(L, null, acc),
|
|
136
134
|
node,
|
|
137
135
|
);
|
|
136
|
+
// WebMCP: register the app's actions as browser tools. Computed per render
|
|
137
|
+
// from the live registry (stable after warmup), gated on agent.webmcp + mcp
|
|
138
|
+
// (execute proxies to /mcp). No actions → no script → page stays zero-JS.
|
|
139
|
+
const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
|
|
140
|
+
const config: DocumentConfig = webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
|
|
138
141
|
const stream = await renderToReadableStream(
|
|
139
|
-
React.createElement(Document, { config
|
|
142
|
+
React.createElement(Document, { config, metadata, children: wrapped }),
|
|
140
143
|
);
|
|
141
144
|
await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
|
|
142
145
|
const html = "<!doctype html>\n" + (await new Response(stream).text());
|
|
@@ -162,9 +165,18 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
async function renderMarkdown(def: BrandedRoute, data: unknown, ctx: RouteContext): Promise<Response> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
// md fn → custom; absent → derive from the json projection (loader data when
|
|
169
|
+
// json is also absent). md/json === false is handled as 404 by the caller.
|
|
170
|
+
const jsonData =
|
|
171
|
+
typeof def.json === "function" ? await def.json(data, ctx) : def.json === false ? null : data;
|
|
172
|
+
const body =
|
|
173
|
+
typeof def.md === "function"
|
|
174
|
+
? await def.md(data, ctx)
|
|
175
|
+
: "```json\n" + JSON.stringify(jsonData, null, 2) + "\n```\n";
|
|
176
|
+
// x-markdown-tokens: a rough estimate (~4 chars/token) agents use to budget.
|
|
177
|
+
return text(body, "text/markdown; charset=utf-8", {
|
|
178
|
+
headers: { "x-markdown-tokens": String(Math.ceil(body.length / 4)) },
|
|
179
|
+
});
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
async function renderProjection(
|
|
@@ -174,21 +186,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
174
186
|
ctx: RouteContext,
|
|
175
187
|
): Promise<Response> {
|
|
176
188
|
const { def, chain } = resolved;
|
|
177
|
-
|
|
189
|
+
// A projection declared `false` is disabled → 404 (and absent from discovery).
|
|
190
|
+
if (def[target] === false) return notFoundResponse(target, ctx.url.pathname);
|
|
178
191
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const body = isResourceManifest(out) ? out.toManifest() : out;
|
|
185
|
-
return text(JSON.stringify(body), "application/vnd.june-agent+json");
|
|
186
|
-
}
|
|
187
|
-
default: {
|
|
188
|
-
const node = def.view ? def.view(data, ctx) : null;
|
|
189
|
-
return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
|
|
190
|
-
}
|
|
192
|
+
if (target === "md") return renderMarkdown(def, data, ctx);
|
|
193
|
+
if (target === "json") {
|
|
194
|
+
// Convention: a json() fn customizes; absent → serialize the loader data.
|
|
195
|
+
const payload = typeof def.json === "function" ? await def.json(data, ctx) : data;
|
|
196
|
+
return Response.json(payload);
|
|
191
197
|
}
|
|
198
|
+
const node = def.view ? def.view(data, ctx) : null;
|
|
199
|
+
return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
async function discovery(url: URL): Promise<Response | null> {
|
package/src/worker.ts
CHANGED
|
@@ -125,3 +125,65 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
125
125
|
},
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// --- the deployed worker's outermost layer (run_worker_first) ---------------
|
|
130
|
+
// Prerendered pages are served as static ASSETS, which bypass the pipeline —
|
|
131
|
+
// so the agent-ready signals the asset layer can't produce get added HERE:
|
|
132
|
+
// 1. `Accept: text/markdown` on a page → the prerendered `.md` asset.
|
|
133
|
+
// 2. a `Link` header on every HTML response (RFC 8288/9727 discovery).
|
|
134
|
+
// 3. `x-markdown-tokens` on markdown responses.
|
|
135
|
+
// Static assets are served by the ASSETS binding (no re-render); only genuinely
|
|
136
|
+
// dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
|
|
137
|
+
// is a transparent pass-through to the pipeline.
|
|
138
|
+
type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
|
|
139
|
+
type FetchPipeline = { fetch(request: Request): Promise<Response> };
|
|
140
|
+
|
|
141
|
+
const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
|
|
142
|
+
|
|
143
|
+
export function withAssets(
|
|
144
|
+
pipeline: FetchPipeline,
|
|
145
|
+
opts: { link?: string | null } = {},
|
|
146
|
+
): { fetch(request: Request, env?: AssetEnv): Promise<Response> } {
|
|
147
|
+
return {
|
|
148
|
+
async fetch(request, env) {
|
|
149
|
+
const assets = env?.ASSETS;
|
|
150
|
+
const url = new URL(request.url);
|
|
151
|
+
const isPagePath = !/\.[a-z0-9]+$/i.test(url.pathname); // no file extension
|
|
152
|
+
const accept = request.headers.get("accept") ?? "";
|
|
153
|
+
|
|
154
|
+
// 1. Markdown content negotiation on a page path → prerendered .md asset.
|
|
155
|
+
if (assets && request.method === "GET" && isPagePath && /text\/markdown/.test(accept)) {
|
|
156
|
+
const base = url.pathname === "/" ? "/index" : url.pathname.replace(/\/+$/, "");
|
|
157
|
+
const mdUrl = new URL(url);
|
|
158
|
+
mdUrl.pathname = `${base}.md`;
|
|
159
|
+
const a = await assets.fetch(new Request(mdUrl.toString(), { headers: request.headers }));
|
|
160
|
+
if (a.ok) {
|
|
161
|
+
const body = await a.text();
|
|
162
|
+
const headers = new Headers(a.headers);
|
|
163
|
+
headers.set("content-type", "text/markdown; charset=utf-8");
|
|
164
|
+
headers.set("x-markdown-tokens", estimateTokens(body));
|
|
165
|
+
if (opts.link) headers.set("link", opts.link);
|
|
166
|
+
return new Response(body, { status: 200, headers });
|
|
167
|
+
}
|
|
168
|
+
// no prerendered .md → fall through; a dynamic route renders it below.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Static assets (prerendered HTML/.md/.json, /client.js) served direct.
|
|
172
|
+
if (assets) {
|
|
173
|
+
const a = await assets.fetch(request);
|
|
174
|
+
if (a.status !== 404) {
|
|
175
|
+
const ct = a.headers.get("content-type") ?? "";
|
|
176
|
+
if (opts.link && ct.includes("text/html") && !a.headers.has("link")) {
|
|
177
|
+
const headers = new Headers(a.headers);
|
|
178
|
+
headers.set("link", opts.link);
|
|
179
|
+
return new Response(a.body, { status: a.status, headers });
|
|
180
|
+
}
|
|
181
|
+
return a;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
|
|
186
|
+
return pipeline.fetch(request);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|