@junejs/server 0.0.9 → 0.0.11
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 +8 -3
- package/src/adapter.ts +226 -0
- package/src/app.ts +65 -3
- package/src/blob.ts +1 -1
- package/src/build.ts +178 -55
- package/src/client-bundle.ts +24 -11
- package/src/css-modules-ambient.d.ts +9 -0
- package/src/css-modules-loader.d.mts +7 -0
- package/src/css-modules-loader.mjs +20 -0
- package/src/css-modules.ts +185 -0
- package/src/css.ts +118 -0
- package/src/d1-migrate.ts +209 -0
- package/src/d1.ts +53 -0
- package/src/db.ts +8 -47
- package/src/deploy.ts +121 -21
- package/src/dev-reload.ts +56 -10
- package/src/dev.ts +32 -1
- package/src/host.ts +7 -78
- package/src/index.ts +28 -1
- package/src/kv.ts +1 -1
- package/src/migrate.ts +122 -0
- package/src/pipeline.ts +119 -18
- package/src/resources.ts +47 -0
- package/src/sqlite-driver.ts +160 -0
- package/src/worker.ts +41 -13
package/src/pipeline.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Bun.* — the dev-only and worker-only concerns (fs route discovery vs frozen
|
|
10
10
|
// manifest) are injected as a RouteResolver, not branched on here.
|
|
11
11
|
|
|
12
|
-
import React from "react";
|
|
12
|
+
import React, { Suspense, use } from "react";
|
|
13
13
|
// renderToReadableStream (NOT renderToStaticMarkup): it is the ONE render
|
|
14
14
|
// function present in every react-dom/server build — node, browser, AND edge.
|
|
15
15
|
// workerd resolves react-dom/server to server.edge (server.browser needs
|
|
@@ -18,6 +18,7 @@ import React from "react";
|
|
|
18
18
|
import { renderToReadableStream } from "react-dom/server";
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
+
LoaderDataContext,
|
|
21
22
|
type BrandedRoute,
|
|
22
23
|
type Metadata,
|
|
23
24
|
type RenderTarget,
|
|
@@ -33,12 +34,15 @@ import {
|
|
|
33
34
|
sitemapXml,
|
|
34
35
|
} from "@junejs/core/discovery";
|
|
35
36
|
import { mcpHandler, mcpTools } from "@junejs/core/mcp";
|
|
37
|
+
|
|
38
|
+
import { ensureScope, runInScope } from "@junejs/db";
|
|
36
39
|
import type { AgentConfig } from "@junejs/core/config";
|
|
37
40
|
import type { Resources } from "@junejs/core/resources";
|
|
38
41
|
|
|
39
42
|
import { negotiate } from "./negotiate";
|
|
40
43
|
|
|
41
44
|
export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
|
|
45
|
+
export type LoadingComponent = React.ComponentType;
|
|
42
46
|
|
|
43
47
|
// What a resolver returns for a matched pathname: the route definition, its
|
|
44
48
|
// params, and the layout chain (root→leaf) that wraps it.
|
|
@@ -46,6 +50,10 @@ export type Resolved = {
|
|
|
46
50
|
def: BrandedRoute;
|
|
47
51
|
params: Record<string, string>;
|
|
48
52
|
chain: LayoutComponent[];
|
|
53
|
+
// The nearest loading.tsx up the segment chain. Its presence opts a route
|
|
54
|
+
// into streaming Suspense: the shell + this fallback flush before load()
|
|
55
|
+
// resolves, then the view streams in.
|
|
56
|
+
loading?: React.ComponentType;
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
// The one thing dev and worker do differently: turn a clean pathname into a
|
|
@@ -93,6 +101,48 @@ function text(body: string, contentType: string, init?: ResponseInit) {
|
|
|
93
101
|
return new Response(body, { ...init, headers });
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
// Make loader data available to the view's descendants via useLoaderData(). The
|
|
105
|
+
// view itself receives data as PROPS (canonical); this provider is the escape
|
|
106
|
+
// hatch for deep children and the Remix-style `const data = useLoaderData()`.
|
|
107
|
+
function provideLoaderData(data: unknown, node: React.ReactNode): React.ReactNode {
|
|
108
|
+
return React.createElement(LoaderDataContext.Provider, { value: data }, node);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The suspending leaf of a streaming route: use() the load promise, then render
|
|
112
|
+
// the view. While the promise is pending the component suspends, so React emits
|
|
113
|
+
// the surrounding Suspense fallback (loading.tsx) in the shell.
|
|
114
|
+
function StreamedView({
|
|
115
|
+
loadPromise,
|
|
116
|
+
def,
|
|
117
|
+
ctx,
|
|
118
|
+
}: {
|
|
119
|
+
loadPromise: Promise<unknown>;
|
|
120
|
+
def: BrandedRoute;
|
|
121
|
+
ctx: RouteContext;
|
|
122
|
+
}): React.ReactNode {
|
|
123
|
+
const data = use(loadPromise);
|
|
124
|
+
return provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
125
|
+
}
|
|
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
|
+
|
|
96
146
|
// The default favicon: the site name's first character in a rounded square —
|
|
97
147
|
// a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
|
|
98
148
|
// names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
|
|
@@ -133,19 +183,52 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
133
183
|
(acc, L) => React.createElement(L, null, acc),
|
|
134
184
|
node,
|
|
135
185
|
);
|
|
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;
|
|
141
186
|
const stream = await renderToReadableStream(
|
|
142
|
-
React.createElement(Document, { config, metadata, children: wrapped }),
|
|
187
|
+
React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
|
|
143
188
|
);
|
|
144
189
|
await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
|
|
145
190
|
const html = "<!doctype html>\n" + (await new Response(stream).text());
|
|
146
191
|
return new Response(html, { status, headers: htmlHeaders() });
|
|
147
192
|
}
|
|
148
193
|
|
|
194
|
+
// WebMCP: register the app's actions as browser tools. Computed per render
|
|
195
|
+
// from the live registry (stable after warmup), gated on agent.webmcp + mcp
|
|
196
|
+
// (execute proxies to /mcp). No actions → no script → page stays zero-JS.
|
|
197
|
+
function docConfigForRender(): DocumentConfig {
|
|
198
|
+
const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
|
|
199
|
+
return webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Streaming Suspense: the shell (layout chain + the loading.tsx fallback)
|
|
203
|
+
// flushes immediately; <StreamedView> use()s the load promise, so React
|
|
204
|
+
// streams the resolved view in once load() settles. Gated by the caller on
|
|
205
|
+
// a present loading.tsx AND static metadata (a data-derived <title> can't
|
|
206
|
+
// stream — the <head> is outside the boundary).
|
|
207
|
+
async function renderStreamingDocument(
|
|
208
|
+
resolved: Resolved,
|
|
209
|
+
loadPromise: Promise<unknown>,
|
|
210
|
+
ctx: RouteContext,
|
|
211
|
+
): Promise<Response> {
|
|
212
|
+
const { def, chain, loading: Loading } = resolved;
|
|
213
|
+
const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
|
|
214
|
+
const boundary = React.createElement(
|
|
215
|
+
Suspense,
|
|
216
|
+
{ fallback: Loading ? React.createElement(Loading) : null },
|
|
217
|
+
leaf,
|
|
218
|
+
);
|
|
219
|
+
const wrapped = chain.reduceRight<React.ReactNode>(
|
|
220
|
+
(acc, L) => React.createElement(L, null, acc),
|
|
221
|
+
boundary,
|
|
222
|
+
);
|
|
223
|
+
const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
|
|
224
|
+
const stream = await renderToReadableStream(
|
|
225
|
+
React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
|
|
226
|
+
{ onError: (e: unknown) => console.error("[june] streaming render error:", e) },
|
|
227
|
+
);
|
|
228
|
+
// NO allReady — return the live stream (shell first), doctype prepended.
|
|
229
|
+
return new Response(withDoctype(stream), { status: 200, headers: htmlHeaders() });
|
|
230
|
+
}
|
|
231
|
+
|
|
149
232
|
function notFoundResponse(target: RenderTarget, pathname: string): Promise<Response> | Response {
|
|
150
233
|
// Data clients get a JSON 404; humans get the rendered NotFound document.
|
|
151
234
|
return target !== "view"
|
|
@@ -195,7 +278,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
195
278
|
const payload = typeof def.json === "function" ? await def.json(data, ctx) : data;
|
|
196
279
|
return Response.json(payload);
|
|
197
280
|
}
|
|
198
|
-
const node = def.view ? def.view(data, ctx) : null;
|
|
281
|
+
const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
199
282
|
return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
|
|
200
283
|
}
|
|
201
284
|
|
|
@@ -219,17 +302,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
219
302
|
}
|
|
220
303
|
}
|
|
221
304
|
|
|
222
|
-
|
|
223
|
-
async fetch(request: Request): Promise<Response> {
|
|
305
|
+
async function handleRequest(request: Request): Promise<Response> {
|
|
224
306
|
const url = new URL(request.url);
|
|
225
307
|
|
|
226
308
|
// --- agent surface ---------------------------------------------------
|
|
227
309
|
if (url.pathname === "/mcp") {
|
|
228
310
|
if (!agent.mcp) return notFoundResponse("view", url.pathname);
|
|
229
|
-
// The agent's tool calls run
|
|
230
|
-
// is
|
|
231
|
-
|
|
232
|
-
return mcpHandler(request, { request
|
|
311
|
+
// The agent's tool calls run inside the same request scope, so an action's
|
|
312
|
+
// ambient `db` is the SAME resource the UI uses (and, once auth is wired,
|
|
313
|
+
// the same principal via ctx). ctx carries identity only — not resources.
|
|
314
|
+
return mcpHandler(request, { request });
|
|
233
315
|
}
|
|
234
316
|
if (request.method === "GET" && agent.discovery) {
|
|
235
317
|
const d = await discovery(url);
|
|
@@ -258,17 +340,25 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
258
340
|
const resolved = await cfg.resolve(pathname);
|
|
259
341
|
if (!resolved) return notFoundResponse(target, pathname);
|
|
260
342
|
|
|
261
|
-
|
|
343
|
+
// ctx is identity/request only; db/kv/blob are ambient (read from the
|
|
344
|
+
// request scope this whole handler runs inside — see runInScope below).
|
|
262
345
|
const ctx: RouteContext = {
|
|
263
346
|
request,
|
|
264
347
|
url,
|
|
265
348
|
params: resolved.params,
|
|
266
349
|
target,
|
|
267
350
|
speculative,
|
|
268
|
-
db: res?.db,
|
|
269
|
-
kv: res?.kv,
|
|
270
|
-
blob: res?.blob,
|
|
271
351
|
};
|
|
352
|
+
// Streaming Suspense: a view request on a route with loading.tsx AND
|
|
353
|
+
// static metadata flushes the shell + fallback before load() resolves.
|
|
354
|
+
// (data-derived metadata can't stream — the <head> needs the title.)
|
|
355
|
+
if (target === "view" && resolved.loading && typeof resolved.def.metadata !== "function") {
|
|
356
|
+
const loadPromise = Promise.resolve(
|
|
357
|
+
resolved.def.load ? resolved.def.load(ctx) : undefined,
|
|
358
|
+
);
|
|
359
|
+
return renderStreamingDocument(resolved, loadPromise, ctx);
|
|
360
|
+
}
|
|
361
|
+
|
|
272
362
|
let data: unknown;
|
|
273
363
|
try {
|
|
274
364
|
data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
|
|
@@ -277,6 +367,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
277
367
|
return notFoundResponse(target, pathname);
|
|
278
368
|
}
|
|
279
369
|
return renderProjection(resolved, target, data, ctx);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
async fetch(request: Request): Promise<Response> {
|
|
374
|
+
// Open the request's resources (memoized; env-bound on workerd) and run the
|
|
375
|
+
// ENTIRE request inside the scope, so ambient db/kv/blob resolve to them in
|
|
376
|
+
// loaders, views, and /mcp actions alike. ensureScope() lazily wires the
|
|
377
|
+
// async-context provider on first request (no static node:* import).
|
|
378
|
+
await ensureScope();
|
|
379
|
+
const resources = cfg.resources ? await cfg.resources() : {};
|
|
380
|
+
return runInScope({ resources }, () => handleRequest(request));
|
|
280
381
|
},
|
|
281
382
|
};
|
|
282
383
|
}
|
package/src/resources.ts
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ResourceConfig, Resources } from "@junejs/core/resources";
|
|
7
7
|
|
|
8
|
+
// Import the EDGE-safe d1 adapter directly (not via ./db, which pulls host/
|
|
9
|
+
// node:* — that would defeat keeping the worker graph host-free).
|
|
10
|
+
import { d1, type D1Database } from "./d1";
|
|
11
|
+
|
|
8
12
|
// Open every declared resource once. Returns a memoized provider so the same
|
|
9
13
|
// long-lived handles are reused across requests (one SQLite connection, etc.).
|
|
14
|
+
// Env-unaware — the dev/Node path, where resources are local processes.
|
|
10
15
|
export function memoizeResources(
|
|
11
16
|
config?: ResourceConfig,
|
|
12
17
|
): () => Promise<Resources> {
|
|
@@ -26,3 +31,45 @@ export function memoizeResources(
|
|
|
26
31
|
return opened;
|
|
27
32
|
};
|
|
28
33
|
}
|
|
34
|
+
|
|
35
|
+
// The deployed worker's env: the platform bindings the adapter declared in
|
|
36
|
+
// wrangler. `DB` is the D1 binding name the workers() adapter emits for a
|
|
37
|
+
// declared `db` resource (see adapter.ts ResourcePlan).
|
|
38
|
+
export type WorkerEnv = { DB?: D1Database } & Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
// Which resources the app declared — a pure descriptor the build bakes into the
|
|
41
|
+
// generated worker entry. The worker resolves them from `env` bindings, NOT from
|
|
42
|
+
// the user's config: importing the config would drag the host-only sqlite()/dev
|
|
43
|
+
// server (node:child_process/http) into the workerd bundle. Local dev opens the
|
|
44
|
+
// real factories instead, through memoizeResources() (see app.ts).
|
|
45
|
+
export type ResourceFlags = { db?: boolean; kv?: boolean; blob?: boolean };
|
|
46
|
+
|
|
47
|
+
// The PROD/worker provider. It binds declared resources to their EDGE handles
|
|
48
|
+
// from env (env.DB → D1) — the prod half of "sqlite dev → D1 prod, one
|
|
49
|
+
// declaration": the same `resources: { db: sqlite() }` runs on D1 at the edge,
|
|
50
|
+
// because D1 *is* SQLite (same SQL, same Juno tables). No env binding → the
|
|
51
|
+
// handle is absent and the route degrades (ctx.db undefined), never a crash.
|
|
52
|
+
// Memoized per isolate (env is stable across requests in a workerd isolate).
|
|
53
|
+
//
|
|
54
|
+
// env is typed `unknown` (it arrives untyped from the runtime) and narrowed to
|
|
55
|
+
// WorkerEnv — keeping the provider assignable to WorkerManifest.resources.
|
|
56
|
+
export function bindWorkerResources(
|
|
57
|
+
flags: ResourceFlags,
|
|
58
|
+
): (env?: unknown) => Promise<Resources> {
|
|
59
|
+
if (!flags.db && !flags.kv && !flags.blob) {
|
|
60
|
+
const empty: Resources = {};
|
|
61
|
+
return () => Promise.resolve(empty);
|
|
62
|
+
}
|
|
63
|
+
let opened: Promise<Resources> | null = null;
|
|
64
|
+
return (rawEnv) => {
|
|
65
|
+
const env = rawEnv as WorkerEnv | undefined;
|
|
66
|
+
if (!opened) {
|
|
67
|
+
opened = (async () => ({
|
|
68
|
+
db: flags.db && env?.DB ? await d1(env.DB).open() : undefined,
|
|
69
|
+
// kv/blob edge bindings (KV/R2) land with their adapters; a declared-but-
|
|
70
|
+
// unbound resource degrades to undefined, the same as db.
|
|
71
|
+
}))();
|
|
72
|
+
}
|
|
73
|
+
return opened;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Local SQLite driver selection — the ONE place that judges which built-in
|
|
2
|
+
// sqlite the current runtime provides, decoupled from the host's server/spawn
|
|
3
|
+
// concerns (host.ts). Both are zero-install: `bun:sqlite` under Bun, `node:sqlite`
|
|
4
|
+
// under Node. This is dev/host-only (the deployed worker binds D1 from env via
|
|
5
|
+
// src/d1.ts and never touches this module).
|
|
6
|
+
//
|
|
7
|
+
// Why a separate layer: the bun-vs-node *driver* choice and the bun-vs-node
|
|
8
|
+
// *host* choice happen to share a signal (`typeof Bun`), but they answer
|
|
9
|
+
// different questions — and the Node path has a real version cliff that deserves
|
|
10
|
+
// one clear error, not a raw ERR_UNKNOWN_BUILTIN_MODULE leaking from a dynamic
|
|
11
|
+
// import. Keeping the judgment here means host.ts just delegates.
|
|
12
|
+
|
|
13
|
+
import type { JuneDb } from "@junejs/core/resources";
|
|
14
|
+
|
|
15
|
+
// The shape bun:sqlite exposes directly and node:sqlite is adapted to: a
|
|
16
|
+
// prepared statement with positional binding.
|
|
17
|
+
type SyncStatement = {
|
|
18
|
+
all(...params: unknown[]): unknown[];
|
|
19
|
+
get(...params: unknown[]): unknown;
|
|
20
|
+
run(...params: unknown[]): { changes: number | bigint; lastInsertRowid?: number | bigint };
|
|
21
|
+
};
|
|
22
|
+
type SyncSqlite = {
|
|
23
|
+
query(sql: string): SyncStatement;
|
|
24
|
+
exec(sql: string): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// node:sqlite landed in v22.5.0 behind --experimental-sqlite, and lost the flag
|
|
29
|
+
// in v22.13.0 (the 22 LTS line) and v23.4.0. We need the flag-free builtin, so
|
|
30
|
+
// 22.13.0 is the practical floor on LTS; 23.4.0 on the odd line.
|
|
31
|
+
export const NODE_SQLITE_MIN_LTS = "22.13.0";
|
|
32
|
+
export const NODE_SQLITE_MIN_ODD = "23.4.0";
|
|
33
|
+
|
|
34
|
+
// Wrap a synchronous SQLite handle as the async JuneDb. The driver work is
|
|
35
|
+
// synchronous, but the SURFACE is async, so swapping in D1 later is invisible to
|
|
36
|
+
// every caller. (Moved here from host.ts with the driver it serves.)
|
|
37
|
+
function asyncSqlite(db: SyncSqlite): JuneDb {
|
|
38
|
+
const self: JuneDb = {
|
|
39
|
+
async query<T>(sql: string, params: unknown[] = []) {
|
|
40
|
+
return db.query(sql).all(...params) as T[];
|
|
41
|
+
},
|
|
42
|
+
async get<T>(sql: string, params: unknown[] = []) {
|
|
43
|
+
// Normalize "no row" to undefined — bun:sqlite returns null, node:sqlite
|
|
44
|
+
// returns undefined; the seam hides the difference.
|
|
45
|
+
return (db.query(sql).get(...params) ?? undefined) as T | undefined;
|
|
46
|
+
},
|
|
47
|
+
async run(sql: string, params: unknown[] = []) {
|
|
48
|
+
const r = db.query(sql).run(...params);
|
|
49
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid ?? 0 };
|
|
50
|
+
},
|
|
51
|
+
async exec(sql: string) {
|
|
52
|
+
db.exec(sql);
|
|
53
|
+
},
|
|
54
|
+
async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
|
|
55
|
+
db.exec("BEGIN");
|
|
56
|
+
try {
|
|
57
|
+
const out = await fn(self); // same connection — sqlite is single-writer
|
|
58
|
+
db.exec("COMMIT");
|
|
59
|
+
return out;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
db.exec("ROLLBACK");
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
async close() {
|
|
66
|
+
db.close();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
return self;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The actionable message when Node can't provide node:sqlite — the bad-first-run
|
|
73
|
+
// path this layer exists to fix. Pure + exported so it is unit-testable without
|
|
74
|
+
// an old Node. `nodeVersion` is process.versions.node (e.g. "20.11.0").
|
|
75
|
+
export function nodeSqliteHelp(nodeVersion: string): string {
|
|
76
|
+
return [
|
|
77
|
+
`June's local database needs Node's built-in sqlite (node:sqlite), which this`,
|
|
78
|
+
`runtime can't load. You're on Node v${nodeVersion}.`,
|
|
79
|
+
``,
|
|
80
|
+
`node:sqlite is available without a flag in Node >= ${NODE_SQLITE_MIN_LTS} (LTS) or`,
|
|
81
|
+
`>= ${NODE_SQLITE_MIN_ODD}. Node 22.5–22.12 / 23.0–23.3 have it behind`,
|
|
82
|
+
`--experimental-sqlite, and older Node doesn't ship it at all.`,
|
|
83
|
+
``,
|
|
84
|
+
`Fix it one of two ways:`,
|
|
85
|
+
` • Upgrade Node to ${NODE_SQLITE_MIN_LTS}+ (recommended), or`,
|
|
86
|
+
` • Run June with Bun — bun:sqlite is built in, no flag, no version floor.`,
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- silencing node:sqlite's one-time ExperimentalWarning -------------------
|
|
91
|
+
// Node prints `ExperimentalWarning: SQLite is an experimental feature` the first
|
|
92
|
+
// time node:sqlite loads. We guard the version, so we KNOW it works here — the
|
|
93
|
+
// warning is just first-run noise. Silence ONLY that one line; every other
|
|
94
|
+
// warning (deprecations, other experimentals) must still pass through.
|
|
95
|
+
|
|
96
|
+
// True for exactly Node's node:sqlite ExperimentalWarning. Pure + exported so
|
|
97
|
+
// the filter is unit-testable without triggering a real warning.
|
|
98
|
+
export function isNodeSqliteExperimentalWarning(type: unknown, message: string): boolean {
|
|
99
|
+
return type === "ExperimentalWarning" && /sqlite/i.test(message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type EmitWarning = (warning: string | Error, ...rest: unknown[]) => void;
|
|
103
|
+
|
|
104
|
+
// Wrap an emitWarning impl so the node:sqlite experimental warning is dropped
|
|
105
|
+
// and everything else is forwarded verbatim.
|
|
106
|
+
export function makeWarningFilter(original: EmitWarning): EmitWarning {
|
|
107
|
+
return (warning, ...rest) => {
|
|
108
|
+
const message = typeof warning === "string" ? warning : (warning?.message ?? "");
|
|
109
|
+
// emitWarning(warning, type?) or emitWarning(warning, { type }) — read both.
|
|
110
|
+
const first = rest[0];
|
|
111
|
+
const type = typeof first === "string" ? first : (first as { type?: string } | undefined)?.type;
|
|
112
|
+
if (isNodeSqliteExperimentalWarning(type, message)) return;
|
|
113
|
+
original(warning, ...rest);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// bun-types declares the global `Bun`; on Node the binding doesn't exist at
|
|
118
|
+
// runtime, which is exactly what the typeof guard checks.
|
|
119
|
+
declare const Bun: unknown;
|
|
120
|
+
|
|
121
|
+
// Open a LOCAL sqlite file (or ":memory:") on whichever runtime we're under.
|
|
122
|
+
// The single seam host.openDb() delegates to.
|
|
123
|
+
export async function openLocalSqlite(path: string): Promise<JuneDb> {
|
|
124
|
+
if (typeof Bun !== "undefined") {
|
|
125
|
+
// Non-literal specifier so a bundler can't FOLLOW bun:sqlite into a Node
|
|
126
|
+
// build (it only exists under Bun, and is only reached under Bun).
|
|
127
|
+
const specifier = "bun:sqlite";
|
|
128
|
+
const { Database } = (await import(specifier)) as {
|
|
129
|
+
Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
|
|
130
|
+
};
|
|
131
|
+
return asyncSqlite(new Database(path, { create: true }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Node: node:sqlite is a builtin, but only flag-free on a recent-enough
|
|
135
|
+
// version. A failed import here is the version cliff — translate it to guidance
|
|
136
|
+
// instead of letting ERR_UNKNOWN_BUILTIN_MODULE / "needs --experimental-sqlite"
|
|
137
|
+
// surface raw.
|
|
138
|
+
const specifier = "node:sqlite";
|
|
139
|
+
let mod: { DatabaseSync: new (p: string) => { prepare(sql: string): SyncStatement; exec(sql: string): void; close(): void } };
|
|
140
|
+
// Drop the SQLite ExperimentalWarning that fires during this import, restoring
|
|
141
|
+
// the original handler immediately after so no other warning is affected.
|
|
142
|
+
const originalEmitWarning = process.emitWarning;
|
|
143
|
+
process.emitWarning = makeWarningFilter(
|
|
144
|
+
originalEmitWarning.bind(process) as EmitWarning,
|
|
145
|
+
) as typeof process.emitWarning;
|
|
146
|
+
try {
|
|
147
|
+
mod = (await import(specifier)) as typeof mod;
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
throw new Error(nodeSqliteHelp(process.versions.node), { cause });
|
|
150
|
+
} finally {
|
|
151
|
+
process.emitWarning = originalEmitWarning;
|
|
152
|
+
}
|
|
153
|
+
const db = new mod.DatabaseSync(path);
|
|
154
|
+
// Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
|
|
155
|
+
return asyncSqlite({
|
|
156
|
+
query: (sql) => db.prepare(sql),
|
|
157
|
+
exec: (sql) => db.exec(sql),
|
|
158
|
+
close: () => db.close(),
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { AgentConfig } from "@junejs/core/config";
|
|
|
16
16
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
17
17
|
import type { Resources } from "@junejs/core/resources";
|
|
18
18
|
|
|
19
|
-
import { createPipeline, type ExtraHandler, type LayoutComponent, type Resolved } from "./pipeline";
|
|
19
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved } from "./pipeline";
|
|
20
20
|
|
|
21
21
|
export type WorkerManifest = {
|
|
22
22
|
// Static paths → route definitions ("/", "/users", ...).
|
|
@@ -26,6 +26,8 @@ export type WorkerManifest = {
|
|
|
26
26
|
// Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
|
|
27
27
|
// freezes the same chain the dev server loads from app/layout.* files.
|
|
28
28
|
layoutChains?: Record<string, LayoutComponent[]>;
|
|
29
|
+
// Nearest loading.tsx per route path → streaming Suspense fallback.
|
|
30
|
+
loadings?: Record<string, LoadingComponent>;
|
|
29
31
|
document: DocumentConfig;
|
|
30
32
|
agent: AgentConfig;
|
|
31
33
|
// Preload Link values (config earlyHints + auto font hints), frozen at build.
|
|
@@ -35,8 +37,9 @@ export type WorkerManifest = {
|
|
|
35
37
|
// The app/_extra.* pre-route handler, imported by the generated entry.
|
|
36
38
|
extra?: ExtraHandler;
|
|
37
39
|
// Opened data resources (db/kv/blob) injected onto ctx. On workerd the D1/KV/R2
|
|
38
|
-
// bindings come from env
|
|
39
|
-
|
|
40
|
+
// bindings come from env, so the generated entry passes an ENV-AWARE provider
|
|
41
|
+
// (bindWorkerResources) — the worker threads its env through on each fetch.
|
|
42
|
+
resources?: (env?: unknown) => Promise<Resources> | Resources;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
|
|
@@ -82,7 +85,9 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
|
|
|
82
85
|
return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
export function createWorker(
|
|
88
|
+
export function createWorker(
|
|
89
|
+
manifest: WorkerManifest,
|
|
90
|
+
): { fetch(request: Request, env?: unknown): Promise<Response> } {
|
|
86
91
|
const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
|
|
87
92
|
...compilePattern(d.pattern),
|
|
88
93
|
def: d.def,
|
|
@@ -94,8 +99,15 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
94
99
|
].sort();
|
|
95
100
|
|
|
96
101
|
const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
|
|
102
|
+
const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
|
|
97
103
|
|
|
98
|
-
|
|
104
|
+
// The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
|
|
105
|
+
// requests in an isolate; we capture the latest and hand it to the env-aware
|
|
106
|
+
// provider, which memoizes the opened handles on first call.
|
|
107
|
+
let currentEnv: unknown;
|
|
108
|
+
const provider = manifest.resources;
|
|
109
|
+
|
|
110
|
+
const pipeline = createPipeline({
|
|
99
111
|
docConfig: manifest.document,
|
|
100
112
|
agent: manifest.agent,
|
|
101
113
|
routeList: () => routeList,
|
|
@@ -103,10 +115,11 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
103
115
|
htmlCacheControl: manifest.htmlCacheControl,
|
|
104
116
|
notFoundComponent: manifest.notFound,
|
|
105
117
|
extra: manifest.extra,
|
|
106
|
-
resources:
|
|
118
|
+
resources: provider ? () => provider(currentEnv) : undefined,
|
|
107
119
|
resolve: async (pathname): Promise<Resolved | null> => {
|
|
108
120
|
const staticDef = manifest.routes[pathname];
|
|
109
|
-
if (staticDef)
|
|
121
|
+
if (staticDef)
|
|
122
|
+
return { def: staticDef, params: {}, chain: chainFor(pathname), loading: loadingFor(pathname) };
|
|
110
123
|
for (const d of dynamic) {
|
|
111
124
|
const m = pathname.match(d.regex);
|
|
112
125
|
if (m) {
|
|
@@ -118,12 +131,19 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
118
131
|
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
119
132
|
}),
|
|
120
133
|
);
|
|
121
|
-
return { def: d.def, params, chain: chainFor(d.pattern) };
|
|
134
|
+
return { def: d.def, params, chain: chainFor(d.pattern), loading: loadingFor(d.pattern) };
|
|
122
135
|
}
|
|
123
136
|
}
|
|
124
137
|
return null;
|
|
125
138
|
},
|
|
126
139
|
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
fetch(request, env) {
|
|
143
|
+
currentEnv = env;
|
|
144
|
+
return pipeline.fetch(request);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
// --- the deployed worker's outermost layer (run_worker_first) ---------------
|
|
@@ -136,7 +156,7 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
136
156
|
// dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
|
|
137
157
|
// is a transparent pass-through to the pipeline.
|
|
138
158
|
type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
|
|
139
|
-
type FetchPipeline = { fetch(request: Request): Promise<Response> };
|
|
159
|
+
type FetchPipeline = { fetch(request: Request, env?: unknown): Promise<Response> };
|
|
140
160
|
|
|
141
161
|
const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
|
|
142
162
|
|
|
@@ -168,14 +188,21 @@ export function withAssets(
|
|
|
168
188
|
// no prerendered .md → fall through; a dynamic route renders it below.
|
|
169
189
|
}
|
|
170
190
|
|
|
171
|
-
// 2. Static assets (prerendered HTML/.md/.json, /client.js
|
|
191
|
+
// 2. Static assets (prerendered HTML/.md/.json, /client.js, hashed CSS)
|
|
192
|
+
// served direct.
|
|
172
193
|
if (assets) {
|
|
173
194
|
const a = await assets.fetch(request);
|
|
174
195
|
if (a.status !== 404) {
|
|
175
196
|
const ct = a.headers.get("content-type") ?? "";
|
|
176
|
-
|
|
197
|
+
const addLink = !!opts.link && ct.includes("text/html") && !a.headers.has("link");
|
|
198
|
+
// Content-hashed assets (e.g. /global.<hash>.css) are immutable — the
|
|
199
|
+
// URL changes when the bytes do, so the browser may cache forever and
|
|
200
|
+
// never revalidate (no 304 round-trip, no stale window).
|
|
201
|
+
const immutable = /\.[a-f0-9]{8,}\.(css|js)$/.test(url.pathname);
|
|
202
|
+
if (addLink || immutable) {
|
|
177
203
|
const headers = new Headers(a.headers);
|
|
178
|
-
headers.set("link", opts.link);
|
|
204
|
+
if (addLink) headers.set("link", opts.link!);
|
|
205
|
+
if (immutable) headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
179
206
|
return new Response(a.body, { status: a.status, headers });
|
|
180
207
|
}
|
|
181
208
|
return a;
|
|
@@ -183,7 +210,8 @@ export function withAssets(
|
|
|
183
210
|
}
|
|
184
211
|
|
|
185
212
|
// 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
|
|
186
|
-
|
|
213
|
+
// Env flows through so the pipeline's resources get the D1/KV/R2 bindings.
|
|
214
|
+
return pipeline.fetch(request, env);
|
|
187
215
|
},
|
|
188
216
|
};
|
|
189
217
|
}
|