@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.8",
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.8",
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 { isRouteDefinition } from "@junejs/core/route";
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 = (await import(pathToFileURL(match.file).href)) as { default?: unknown };
121
- if (!isRouteDefinition(mod.default)) return null;
122
- return { def: mod.default, params: match.params, chain: await loadChain(match.segments) };
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 { isRouteDefinition, type BrandedRoute } from "@junejs/core/route";
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 = (await import(pathToFileURL(r.file).href)) as { default?: unknown };
175
- if (!isRouteDefinition(mod.default)) continue;
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: mod.default });
180
+ dynamicRoutes.push({ pattern: r.path, def });
179
181
  layoutChains[r.path] = chain;
180
182
  } else {
181
- routes[r.path] = mod.default;
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
- const imports: string[] = [`import { createWorker } from "@junejs/server/worker";`];
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
- export default createWorker({
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
- const targets: Array<[string, string]> = [[r.path, `${stem}.html`]];
314
- if (r.path !== "/") {
315
- targets.push([`${r.path}.md`, `${stem}.md`]);
316
- if (typeof def.json === "function") targets.push([`${r.path}.json`, `${stem}.json`]);
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
- ...(hasAssets ? { assets: { directory: "./assets" } } : {}),
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: docConfig, metadata, children: wrapped }),
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
- if (def.md) return text(await def.md(data, ctx), "text/markdown; charset=utf-8");
166
- const payload = def.json ? await def.json(data, ctx) : data;
167
- return text("```json\n" + JSON.stringify(payload, null, 2) + "\n```\n", "text/markdown; charset=utf-8");
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
- if (target === "md") return renderMarkdown(def, data, ctx);
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
- switch (resolveProjection(def, target)) {
180
- case "json":
181
- return Response.json(await def.json!(data, ctx));
182
- case "agent": {
183
- const out = await def.agent!(data, ctx);
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
+ }