@junejs/server 0.0.13 → 0.0.14

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.13",
3
+ "version": "0.0.14",
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",
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.13",
34
- "@junejs/db": "0.0.13",
33
+ "@junejs/core": "0.0.14",
34
+ "@junejs/db": "0.0.14",
35
35
  "marked": "^18.0.5",
36
36
  "rolldown": "^1.1.0",
37
37
  "postcss": "^8.4.0",
package/src/app.ts CHANGED
@@ -112,6 +112,12 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
112
112
 
113
113
  const resources = memoizeResources(config.resources);
114
114
 
115
+ // Boot the opt-in Tier-3 data layer (e.g. `dataLayer: junoDataLayer()`) once:
116
+ // its install() wires the ambient `db` (Juno registers its SQL tagger). Explicit
117
+ // and config-declared — no import-time side-effect, and the framework still never
118
+ // imports the data layer (the user's config does).
119
+ config.dataLayer?.install();
120
+
115
121
  // The app's not-found.tsx is part of the dev surface too (the build freezes
116
122
  // it into the manifest as `notFound`), and importing it is async — so the
117
123
  // pipeline is built lazily on first fetch, memoized after.
package/src/build.ts CHANGED
@@ -384,10 +384,20 @@ export async function juneBuild(
384
384
  const resourcesField = hasResources
385
385
  ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
386
386
  : "";
387
+
388
+ // Opt-in Tier-3 data layer: import its installDataLayer from the declared module
389
+ // and call it at worker boot — the prod twin of the dev host's dataLayer.install()
390
+ // (createApp). The user's config names the module; the framework never hard-codes it.
391
+ const dataLayerModule = fullConfig.dataLayer?.module;
392
+ if (dataLayerModule) {
393
+ imports.push(`import { installDataLayer } from ${JSON.stringify(dataLayerModule)};`);
394
+ }
395
+ const dataLayerBoot = dataLayerModule ? "\ninstallDataLayer();\n" : "";
396
+
387
397
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
388
398
  ${adapterEntry.imports.join("\n")}
389
399
  ${imports.join("\n")}
390
-
400
+ ${dataLayerBoot}
391
401
  const pipeline = createWorker({
392
402
  routes: {
393
403
  ${statics.join("\n")}
package/src/dev-reload.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // Dev live-reload — the browser half of the watch story. The supervisor
2
2
  // (cli watch.ts) restarts the SERVER on save; this module makes the BROWSER
3
3
  // follow: every dev HTML page gets a tiny script that holds an SSE connection
4
- // to /__june/events. A restart drops the connection; the script reconnects
5
- // and reloads on success. No version tokens, no diffing — the dropped socket
6
- // IS the signal, which is exactly right for a process-restart reload model.
4
+ // to /__june/events. A restart drops the connection; the script reconnects,
5
+ // and on success it tries a push-HMR MORPH (window.__juneLiveReload, set by the
6
+ // islands runtime when clientRouter is on): re-fetch the current page's fragment
7
+ // from the fresh server and morph it in place — island state, focus, and scroll
8
+ // survive the edit. It falls back to a full reload when there's no morph hook
9
+ // (no clientRouter) or the morph can't apply. The dropped socket IS the signal.
7
10
  //
8
11
  // This lives in the startDevServer WRAPPER, never in the pipeline: dev/built
9
12
  // parity (parity.test.ts compares pipeline outputs byte-for-byte) stays
@@ -32,7 +35,15 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
32
35
  const connect = () => {
33
36
  const es = new EventSource(${JSON.stringify(EVENTS_PATH)});
34
37
  es.addEventListener("open", () => {
35
- if (dropped) location.reload();
38
+ if (dropped) {
39
+ // push-HMR: morph the freshly-restarted page in place (state survives);
40
+ // hard-reload only if there's no morph hook or it can't apply.
41
+ const hot = window.__juneLiveReload;
42
+ (hot ? hot() : Promise.resolve(false)).then(
43
+ (ok) => { if (!ok) location.reload(); },
44
+ () => location.reload(),
45
+ );
46
+ }
36
47
  dropped = false;
37
48
  });
38
49
  es.addEventListener("css", swapCss); // stylesheet edit → swap, no reload
package/src/index.ts CHANGED
@@ -20,9 +20,11 @@ export {
20
20
  type WorkerEnv,
21
21
  type ResourceFlags,
22
22
  } from "./resources";
23
- // Ambient data resources (db/kv/blob) live in their own package, `@junejs/db` —
24
- // `import { db } from "@junejs/db"`. The host opens the resources and runs each
25
- // request in the scope they read (pipeline imports runInScope from @junejs/db).
23
+ // Ambient data resources (db/kv/blob) live in `@junejs/db`; re-exported here so
24
+ // `import { db } from "@junejs/server"` is the ONE canonical handle. It auto-tags
25
+ // raw queries when Juno is installed (Juno registers the tagger via @junejs/db) —
26
+ // so the framework never imports Juno, yet `db` is the tagging one in a Juno app.
27
+ export { db, kv, blob } from "@junejs/db";
26
28
  export { loadJuneConfig } from "./config-loader";
27
29
  export {
28
30
  migrate,
package/src/negotiate.ts CHANGED
@@ -8,12 +8,19 @@
8
8
 
9
9
  import type { RenderTarget } from "@junejs/core/route";
10
10
 
11
+ // The fragment media type + title header are the client-router wire protocol —
12
+ // defined once in @junejs/core so the browser router and this negotiator share
13
+ // the exact strings. Re-exported so existing server-side importers are unaffected.
14
+ export { FRAGMENT_ACCEPT, TITLE_HEADER } from "@junejs/core/nav-protocol";
15
+
11
16
  const EXT_TARGET: Record<string, RenderTarget> = {
12
17
  ".json": "json",
13
18
  ".md": "md",
14
19
  };
15
20
 
16
21
  const ACCEPT_TARGET: Array<[test: RegExp, target: RenderTarget]> = [
22
+ // fragment first: its media type is exact + can't collide with browser Accepts.
23
+ [/text\/vnd\.june\.fragment\+html/, "fragment"],
17
24
  [/text\/markdown/, "md"],
18
25
  [/application\/json/, "json"],
19
26
  ];
package/src/pipeline.ts CHANGED
@@ -39,7 +39,7 @@ import { ensureScope, runInScope } from "@junejs/db";
39
39
  import type { AgentConfig } from "@junejs/core/config";
40
40
  import type { Resources } from "@junejs/core/resources";
41
41
 
42
- import { negotiate } from "./negotiate";
42
+ import { negotiate, TITLE_HEADER } from "./negotiate";
43
43
 
44
44
  export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
45
45
  export type LoadingComponent = React.ComponentType;
@@ -191,6 +191,30 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
191
191
  return new Response(html, { status, headers: htmlHeaders() });
192
192
  }
193
193
 
194
+ // Route A: the [data-june-root] inner HTML for a soft-nav / live-apply request
195
+ // — the chain-wrapped view rendered WITHOUT the Document shell, so it is
196
+ // byte-identical to what a full load puts inside [data-june-root] (the morph
197
+ // parity contract). The title rides back in a header so the client updates
198
+ // document.title without parsing the body. allReady (no streamed fallback) so
199
+ // the applied DOM is complete.
200
+ async function renderFragment(
201
+ node: React.ReactNode,
202
+ metadata: Metadata | undefined,
203
+ chain: LayoutComponent[],
204
+ ): Promise<Response> {
205
+ const wrapped = chain.reduceRight<React.ReactNode>(
206
+ (acc, L) => React.createElement(L, null, acc),
207
+ node,
208
+ );
209
+ const stream = await renderToReadableStream(wrapped);
210
+ await stream.allReady;
211
+ const html = await new Response(stream).text();
212
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
213
+ const title = typeof metadata?.title === "string" ? metadata.title : undefined;
214
+ if (title) headers.set(TITLE_HEADER, title);
215
+ return new Response(html, { status: 200, headers });
216
+ }
217
+
194
218
  // WebMCP: register the app's actions as browser tools. Computed per render
195
219
  // from the live registry (stable after warmup), gated on agent.webmcp + mcp
196
220
  // (execute proxies to /mcp). No actions → no script → page stays zero-JS.
@@ -270,7 +294,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
270
294
  ): Promise<Response> {
271
295
  const { def, chain } = resolved;
272
296
  // A projection declared `false` is disabled → 404 (and absent from discovery).
273
- if (def[target] === false) return notFoundResponse(target, ctx.url.pathname);
297
+ // "fragment" isn't a declarable projection (it's the view rendered without the
298
+ // shell), so it's never disabled — exclude it from the check.
299
+ if (target !== "fragment" && def[target] === false) {
300
+ return notFoundResponse(target, ctx.url.pathname);
301
+ }
274
302
 
275
303
  if (target === "md") return renderMarkdown(def, data, ctx);
276
304
  if (target === "json") {
@@ -279,7 +307,9 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
279
307
  return Response.json(payload);
280
308
  }
281
309
  const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
282
- return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
310
+ const meta = resolveMeta(def, data, ctx);
311
+ if (target === "fragment") return renderFragment(node, meta, chain);
312
+ return renderDocument(node, meta, 200, chain);
283
313
  }
284
314
 
285
315
  async function discovery(url: URL): Promise<Response | null> {
@@ -35,21 +35,36 @@ export const NODE_SQLITE_MIN_ODD = "23.4.0";
35
35
  // synchronous, but the SURFACE is async, so swapping in D1 later is invisible to
36
36
  // every caller. (Moved here from host.ts with the driver it serves.)
37
37
  function asyncSqlite(db: SyncSqlite): JuneDb {
38
+ // Prepared-statement cache, keyed by SQL string — compile-once at the driver:
39
+ // parse + bytecode-compile each query ONCE, then re-bind. Essential on the
40
+ // node:sqlite path (`db.query` = `db.prepare`, which re-compiles every call);
41
+ // harmless on bun:sqlite (it already caches `query()` internally). The app's set
42
+ // of query SHAPES is finite (Juno compiles one per shape), so this stays bounded.
43
+ // Statements re-bind across calls — exactly what sqlite prepares are for.
44
+ const stmts = new Map<string, SyncStatement>();
45
+ const prep = (sql: string): SyncStatement => {
46
+ let s = stmts.get(sql);
47
+ if (!s) {
48
+ s = db.query(sql);
49
+ stmts.set(sql, s);
50
+ }
51
+ return s;
52
+ };
38
53
  const self: JuneDb = {
39
54
  async query<T>(sql: string, params: unknown[] = []) {
40
- return db.query(sql).all(...params) as T[];
55
+ return prep(sql).all(...params) as T[];
41
56
  },
42
57
  async get<T>(sql: string, params: unknown[] = []) {
43
58
  // Normalize "no row" to undefined — bun:sqlite returns null, node:sqlite
44
59
  // returns undefined; the seam hides the difference.
45
- return (db.query(sql).get(...params) ?? undefined) as T | undefined;
60
+ return (prep(sql).get(...params) ?? undefined) as T | undefined;
46
61
  },
47
62
  async run(sql: string, params: unknown[] = []) {
48
- const r = db.query(sql).run(...params);
63
+ const r = prep(sql).run(...params);
49
64
  return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid ?? 0 };
50
65
  },
51
66
  async exec(sql: string) {
52
- db.exec(sql);
67
+ db.exec(sql); // DDL / multi-statement — not a cacheable prepared statement
53
68
  },
54
69
  async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
55
70
  db.exec("BEGIN");
@@ -63,6 +78,7 @@ function asyncSqlite(db: SyncSqlite): JuneDb {
63
78
  }
64
79
  },
65
80
  async close() {
81
+ stmts.clear();
66
82
  db.close();
67
83
  },
68
84
  };