@junejs/server 0.0.13 → 0.0.15

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.15",
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.15",
34
+ "@junejs/db": "0.0.15",
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;
@@ -124,25 +124,6 @@ function StreamedView({
124
124
  return provideLoaderData(data, def.view ? def.view(data, ctx) : null);
125
125
  }
126
126
 
127
- // renderToReadableStream does not emit the doctype; prepend it without buffering
128
- // the React stream (streaming stays incremental).
129
- function withDoctype(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
130
- const reader = source.getReader();
131
- return new ReadableStream<Uint8Array>({
132
- start(controller) {
133
- controller.enqueue(new TextEncoder().encode("<!doctype html>\n"));
134
- },
135
- async pull(controller) {
136
- const { done, value } = await reader.read();
137
- if (done) controller.close();
138
- else controller.enqueue(value);
139
- },
140
- cancel(reason) {
141
- void reader.cancel(reason);
142
- },
143
- });
144
- }
145
-
146
127
  // The default favicon: the site name's first character in a rounded square —
147
128
  // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
148
129
  // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
@@ -187,8 +168,33 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
187
168
  React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
188
169
  );
189
170
  await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
190
- const html = "<!doctype html>\n" + (await new Response(stream).text());
191
- return new Response(html, { status, headers: htmlHeaders() });
171
+ // React 19 emits <!DOCTYPE html> itself for an <html> root — don't prepend a
172
+ // second one.
173
+ return new Response(stream, { status, headers: htmlHeaders() });
174
+ }
175
+
176
+ // Route A: the [data-june-root] inner HTML for a soft-nav / live-apply request
177
+ // — the chain-wrapped view rendered WITHOUT the Document shell, so it is
178
+ // byte-identical to what a full load puts inside [data-june-root] (the morph
179
+ // parity contract). The title rides back in a header so the client updates
180
+ // document.title without parsing the body. allReady (no streamed fallback) so
181
+ // the applied DOM is complete.
182
+ async function renderFragment(
183
+ node: React.ReactNode,
184
+ metadata: Metadata | undefined,
185
+ chain: LayoutComponent[],
186
+ ): Promise<Response> {
187
+ const wrapped = chain.reduceRight<React.ReactNode>(
188
+ (acc, L) => React.createElement(L, null, acc),
189
+ node,
190
+ );
191
+ const stream = await renderToReadableStream(wrapped);
192
+ await stream.allReady;
193
+ const html = await new Response(stream).text();
194
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
195
+ const title = typeof metadata?.title === "string" ? metadata.title : undefined;
196
+ if (title) headers.set(TITLE_HEADER, title);
197
+ return new Response(html, { status: 200, headers });
192
198
  }
193
199
 
194
200
  // WebMCP: register the app's actions as browser tools. Computed per render
@@ -225,8 +231,9 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
225
231
  React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
226
232
  { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
227
233
  );
228
- // NO allReady — return the live stream (shell first), doctype prepended.
229
- return new Response(withDoctype(stream), { status: 200, headers: htmlHeaders() });
234
+ // NO allReady — return the live stream (shell first). React 19 streams the
235
+ // <!DOCTYPE html> as the first bytes, so nothing to prepend.
236
+ return new Response(stream, { status: 200, headers: htmlHeaders() });
230
237
  }
231
238
 
232
239
  function notFoundResponse(target: RenderTarget, pathname: string): Promise<Response> | Response {
@@ -270,8 +277,29 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
270
277
  ): Promise<Response> {
271
278
  const { def, chain } = resolved;
272
279
  // A projection declared `false` is disabled → 404 (and absent from discovery).
273
- if (def[target] === false) return notFoundResponse(target, ctx.url.pathname);
280
+ // "fragment" isn't a declarable projection (it's the view rendered without the
281
+ // shell), so it's never disabled — exclude it from the check.
282
+ if (target !== "fragment" && def[target] === false) {
283
+ return notFoundResponse(target, ctx.url.pathname);
284
+ }
285
+
286
+ const res = await renderTarget(target, def, data, ctx, chain);
287
+ // Every projection here is chosen by the Accept header at the SAME url (a full
288
+ // page, its fragment, its .md and .json all live at the clean path). Without
289
+ // Vary, a shared/browser cache can hand a soft-nav fragment to a real page
290
+ // load — which surfaces as a document that starts mid-body. Make Accept part
291
+ // of the cache key.
292
+ res.headers.set("vary", "accept");
293
+ return res;
294
+ }
274
295
 
296
+ async function renderTarget(
297
+ target: RenderTarget,
298
+ def: Resolved["def"],
299
+ data: unknown,
300
+ ctx: RouteContext,
301
+ chain: LayoutComponent[],
302
+ ): Promise<Response> {
275
303
  if (target === "md") return renderMarkdown(def, data, ctx);
276
304
  if (target === "json") {
277
305
  // Convention: a json() fn customizes; absent → serialize the loader data.
@@ -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
  };