@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 +3 -3
- package/src/app.ts +6 -0
- package/src/build.ts +11 -1
- package/src/dev-reload.ts +15 -4
- package/src/index.ts +5 -3
- package/src/negotiate.ts +7 -0
- package/src/pipeline.ts +56 -26
- package/src/sqlite-driver.ts +20 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
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
|
|
6
|
-
//
|
|
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)
|
|
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
|
|
24
|
-
// `import { db } from "@junejs/
|
|
25
|
-
//
|
|
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
|
-
|
|
191
|
-
|
|
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)
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/sqlite-driver.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
60
|
+
return (prep(sql).get(...params) ?? undefined) as T | undefined;
|
|
46
61
|
},
|
|
47
62
|
async run(sql: string, params: unknown[] = []) {
|
|
48
|
-
const r =
|
|
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
|
};
|