@junejs/server 0.0.2
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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +46 -0
- package/src/app.ts +155 -0
- package/src/blob.ts +99 -0
- package/src/build.ts +367 -0
- package/src/client-bundle.ts +71 -0
- package/src/config-loader.ts +30 -0
- package/src/content.ts +102 -0
- package/src/db.ts +61 -0
- package/src/deploy.ts +72 -0
- package/src/dev-reload.ts +77 -0
- package/src/dev.ts +41 -0
- package/src/host.ts +234 -0
- package/src/index.ts +42 -0
- package/src/instrumentation.ts +33 -0
- package/src/kv.ts +34 -0
- package/src/negotiate.ts +57 -0
- package/src/pipeline.ts +248 -0
- package/src/resources.ts +28 -0
- package/src/router.ts +263 -0
- package/src/worker.ts +101 -0
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `june deploy` — ship a June app. Workers target (v1).
|
|
2
|
+
//
|
|
3
|
+
// The verb is fixed, the target is an adapter (same seam philosophy as
|
|
4
|
+
// JuneHost): today this orchestrates `june build` + wrangler (which owns auth,
|
|
5
|
+
// wasm/ttf rules, and the upload API); a future "june-cloud" target swaps the
|
|
6
|
+
// adapter, not the CLI. --dry-run is the CI test (build + wrangler validate,
|
|
7
|
+
// no upload).
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { juneBuild } from "./build";
|
|
12
|
+
import { loadJuneConfig } from "./config-loader";
|
|
13
|
+
|
|
14
|
+
export type DeployResult = {
|
|
15
|
+
url: string | null;
|
|
16
|
+
dryRun: boolean;
|
|
17
|
+
configPath: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function juneDeploy(
|
|
21
|
+
appRoot: string,
|
|
22
|
+
options: { dryRun?: boolean; skipBuild?: boolean } = {},
|
|
23
|
+
): Promise<DeployResult> {
|
|
24
|
+
const cfg = await loadJuneConfig(appRoot);
|
|
25
|
+
const target = cfg.deploy?.target ?? "workers";
|
|
26
|
+
if (target !== "workers") throw new Error(`unknown deploy target: ${target}`);
|
|
27
|
+
|
|
28
|
+
if (!options.skipBuild) {
|
|
29
|
+
const built = await juneBuild(appRoot);
|
|
30
|
+
console.log(`built ${built.outFile}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The app's own wrangler config wins; otherwise the one `june build` emits next
|
|
34
|
+
// to the bundle (its `main` is relative to the config file).
|
|
35
|
+
const configPath =
|
|
36
|
+
[join(appRoot, "wrangler.toml"), join(appRoot, "wrangler.jsonc")].find(existsSync) ??
|
|
37
|
+
join(appRoot, "dist/wrangler.jsonc");
|
|
38
|
+
if (!existsSync(configPath)) {
|
|
39
|
+
throw new Error(`no wrangler config found (expected ${configPath}) — run june build first`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!options.dryRun && !process.env.CLOUDFLARE_API_TOKEN) {
|
|
43
|
+
console.log(
|
|
44
|
+
"note: CLOUDFLARE_API_TOKEN not set — wrangler will fall back to its own login\n" +
|
|
45
|
+
" (run `bunx wrangler login` once, or export the token).",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pinned: assets-mode needs wrangler ≥4.99 (reminder #7).
|
|
50
|
+
const args = ["bunx", "wrangler@4.99.0", "deploy", "--config", configPath];
|
|
51
|
+
if (options.dryRun) args.push("--dry-run");
|
|
52
|
+
|
|
53
|
+
const proc = Bun.spawn(args, { cwd: appRoot, stdout: "pipe", stderr: "pipe" });
|
|
54
|
+
const [out, err, code] = await Promise.all([
|
|
55
|
+
new Response(proc.stdout).text(),
|
|
56
|
+
new Response(proc.stderr).text(),
|
|
57
|
+
proc.exited,
|
|
58
|
+
]);
|
|
59
|
+
if (out.trim()) console.log(out.trimEnd());
|
|
60
|
+
if (code !== 0) {
|
|
61
|
+
if (/authentication|login|10000/i.test(err + out)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"wrangler authentication failed — set CLOUDFLARE_API_TOKEN or run `bunx wrangler login`.\n" +
|
|
64
|
+
err.trim(),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`wrangler deploy failed (exit ${code})\n${err.trim()}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const url = out.match(/https:\/\/\S+\.workers\.dev\S*/)?.[0] ?? null;
|
|
71
|
+
return { url, dryRun: options.dryRun ?? false, configPath };
|
|
72
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Dev live-reload — the browser half of the watch story. The supervisor
|
|
2
|
+
// (cli watch.ts) restarts the SERVER on save; this module makes the BROWSER
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// This lives in the startDevServer WRAPPER, never in the pipeline: dev/built
|
|
9
|
+
// parity (parity.test.ts compares pipeline outputs byte-for-byte) stays
|
|
10
|
+
// untouched, and nothing here can leak into a build.
|
|
11
|
+
|
|
12
|
+
const EVENTS_PATH = "/__june/events";
|
|
13
|
+
const SCRIPT_PATH = "/__june/reload.js";
|
|
14
|
+
|
|
15
|
+
const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → location.reload()
|
|
16
|
+
(() => {
|
|
17
|
+
let dropped = false;
|
|
18
|
+
const connect = () => {
|
|
19
|
+
const es = new EventSource(${JSON.stringify(EVENTS_PATH)});
|
|
20
|
+
es.addEventListener("open", () => {
|
|
21
|
+
if (dropped) location.reload();
|
|
22
|
+
dropped = false;
|
|
23
|
+
});
|
|
24
|
+
es.addEventListener("error", () => {
|
|
25
|
+
dropped = true;
|
|
26
|
+
// EventSource auto-retries while the connection is flaky, but goes to
|
|
27
|
+
// CLOSED (and stays there) on hard failures like a refused connection
|
|
28
|
+
// mid-restart — recreate it ourselves in that case.
|
|
29
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
30
|
+
es.close();
|
|
31
|
+
setTimeout(connect, 300);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
connect();
|
|
36
|
+
})();
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function devEvents(): Response {
|
|
40
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
41
|
+
start(controller) {
|
|
42
|
+
// One greeting so the browser fires `open`; then the socket just stays
|
|
43
|
+
// up until this process dies — the restart is the next event.
|
|
44
|
+
controller.enqueue(new TextEncoder().encode("retry: 300\n\ndata: connected\n\n"));
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return new Response(stream, {
|
|
48
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-store" },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Wrap the app's fetch: answer the two dev endpoints, and inject the reload
|
|
53
|
+
// script into every HTML document on its way out.
|
|
54
|
+
export function withLiveReload(
|
|
55
|
+
fetchApp: (req: Request) => Promise<Response>,
|
|
56
|
+
): (req: Request) => Promise<Response> {
|
|
57
|
+
return async (req) => {
|
|
58
|
+
const { pathname } = new URL(req.url);
|
|
59
|
+
if (pathname === EVENTS_PATH) return devEvents();
|
|
60
|
+
if (pathname === SCRIPT_PATH) {
|
|
61
|
+
return new Response(RELOAD_JS, {
|
|
62
|
+
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const res = await fetchApp(req);
|
|
67
|
+
if (!res.headers.get("content-type")?.includes("text/html")) return res;
|
|
68
|
+
// Dev HTML is fully buffered by the pipeline already (allReady), so a
|
|
69
|
+
// text() round-trip costs nothing.
|
|
70
|
+
const html = await res.text();
|
|
71
|
+
const tag = `<script src="${SCRIPT_PATH}" defer></script>`;
|
|
72
|
+
const injected = html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : html + tag;
|
|
73
|
+
const headers = new Headers(res.headers);
|
|
74
|
+
headers.delete("content-length"); // the body just grew; let the host recompute
|
|
75
|
+
return new Response(injected, { status: res.status, headers });
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `june dev` — wire the request pipeline to a host and listen.
|
|
2
|
+
//
|
|
3
|
+
// Steps: install the async-context provider (so tracing + cache auto-tagging
|
|
4
|
+
// work), load june.config.ts from the app root (the config the PoC forgot to
|
|
5
|
+
// read), build the app, and serve through the detected JuneHost.
|
|
6
|
+
|
|
7
|
+
import { loadJuneConfig } from "./config-loader";
|
|
8
|
+
import { installAsyncContext } from "./instrumentation";
|
|
9
|
+
import { createApp } from "./app";
|
|
10
|
+
import { withLiveReload } from "./dev-reload";
|
|
11
|
+
import { host as defaultHost, type JuneHost, type ServeHandle } from "./host";
|
|
12
|
+
|
|
13
|
+
export type DevServerOptions = {
|
|
14
|
+
appDir: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
host?: JuneHost;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type DevServer = ServeHandle & { url: string };
|
|
20
|
+
|
|
21
|
+
export async function startDevServer({
|
|
22
|
+
appDir,
|
|
23
|
+
port = 3000,
|
|
24
|
+
host = defaultHost,
|
|
25
|
+
}: DevServerOptions): Promise<DevServer> {
|
|
26
|
+
await installAsyncContext();
|
|
27
|
+
const config = await loadJuneConfig(appDir);
|
|
28
|
+
const app = createApp({ appDir, config });
|
|
29
|
+
await app.warmup();
|
|
30
|
+
|
|
31
|
+
// Live reload wraps the DEV SERVER only — the pipeline (and therefore
|
|
32
|
+
// dev/built parity) never sees it. See dev-reload.ts.
|
|
33
|
+
const handle = host.serve(withLiveReload((req) => app.fetch(req)), {
|
|
34
|
+
port,
|
|
35
|
+
earlyHints: () => app.earlyHints(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const url = `http://localhost:${handle.port}`;
|
|
39
|
+
console.log(`june dev → ${url} (host: ${host.name})`);
|
|
40
|
+
return { ...handle, url };
|
|
41
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The HOST INTERFACE — the seam between June's portable core and the JS runtime
|
|
2
|
+
// it runs on. The core speaks Web standards (Request/Response/ReadableStream/
|
|
3
|
+
// crypto) plus THIS object for the few things standards don't cover: binding a
|
|
4
|
+
// port, spawning the react-server Flight subprocess, and the database.
|
|
5
|
+
//
|
|
6
|
+
// A new deploy target (Workers, creekd, Vercel) is a new implementation of this
|
|
7
|
+
// interface, not a change to the framework. This package is the HOST layer —
|
|
8
|
+
// static `node:*` imports are fine here (Bun implements the node: builtins too);
|
|
9
|
+
// it is the pure `@junejs/core` package, never this one, that must stay node-free.
|
|
10
|
+
//
|
|
11
|
+
// ASYNC-FIRST DB (the deliberate redesign): the PoC shipped a SYNCHRONOUS
|
|
12
|
+
// SqliteDb surface — and that was the one dead end. D1 and every edge database
|
|
13
|
+
// are async; an API that returns rows synchronously cannot be implemented on
|
|
14
|
+
// them without blocking or buffering hacks. So `JuneDb` is async from day one;
|
|
15
|
+
// the Bun/Node SQLite drivers (which happen to be sync) are wrapped, and D1
|
|
16
|
+
// (Phase 5) implements the same interface natively.
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { Readable } from "node:stream";
|
|
21
|
+
|
|
22
|
+
// The JuneDb / RunResult CONTRACT now lives in the pure @junejs/core layer
|
|
23
|
+
// (@junejs/core/resources) so any ORM can target it. Re-exported here for callers
|
|
24
|
+
// that still import from the host.
|
|
25
|
+
import type { JuneDb, RunResult } from "@junejs/core/resources";
|
|
26
|
+
export type { JuneDb, RunResult };
|
|
27
|
+
|
|
28
|
+
export type ServeHandle = { port: number; stop(force?: boolean): void };
|
|
29
|
+
|
|
30
|
+
export type SpawnedModule = {
|
|
31
|
+
stdout: ReadableStream<Uint8Array>;
|
|
32
|
+
stderrText(): Promise<string>;
|
|
33
|
+
exited: Promise<number>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface JuneHost {
|
|
37
|
+
readonly name: "bun" | "node";
|
|
38
|
+
serve(
|
|
39
|
+
handler: (req: Request) => Promise<Response>,
|
|
40
|
+
opts: { port: number; earlyHints?: () => string[] },
|
|
41
|
+
): ServeHandle;
|
|
42
|
+
// Spawn a module in a child runtime (the react-server Flight renderer, which
|
|
43
|
+
// must run under a different module-resolution condition). Phase 4 supersedes
|
|
44
|
+
// this with the in-isolate dual-graph loader; the seam stays for the fallback.
|
|
45
|
+
spawnModule(entry: string, args: string[], opts: { conditions?: string[] }): SpawnedModule;
|
|
46
|
+
// Open a LOCAL SQLite database — the internal primitive the `sqlite()` db
|
|
47
|
+
// adapter builds on (docs/data-layer-boundary.md: openDb is demoted from the
|
|
48
|
+
// user-facing API to a host primitive; apps declare `resources.db` instead).
|
|
49
|
+
openDb(path: string): Promise<JuneDb>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- a tiny sync→async SQLite adapter shared by both hosts ------------------
|
|
53
|
+
|
|
54
|
+
// The shape bun:sqlite exposes directly and node:sqlite is adapted to: a
|
|
55
|
+
// prepared statement with positional binding.
|
|
56
|
+
type SyncStatement = {
|
|
57
|
+
all(...params: unknown[]): unknown[];
|
|
58
|
+
get(...params: unknown[]): unknown;
|
|
59
|
+
run(...params: unknown[]): { changes: number | bigint; lastInsertRowid?: number | bigint };
|
|
60
|
+
};
|
|
61
|
+
type SyncSqlite = {
|
|
62
|
+
query(sql: string): SyncStatement;
|
|
63
|
+
exec(sql: string): void;
|
|
64
|
+
close(): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Wrap a synchronous SQLite handle as the async JuneDb. The driver work is
|
|
68
|
+
// synchronous, but the SURFACE is async, so swapping in D1 later is invisible
|
|
69
|
+
// to every caller.
|
|
70
|
+
function asyncSqlite(db: SyncSqlite): JuneDb {
|
|
71
|
+
const self: JuneDb = {
|
|
72
|
+
async query<T>(sql: string, params: unknown[] = []) {
|
|
73
|
+
return db.query(sql).all(...params) as T[];
|
|
74
|
+
},
|
|
75
|
+
async get<T>(sql: string, params: unknown[] = []) {
|
|
76
|
+
// Normalize "no row" to undefined — bun:sqlite returns null, node:sqlite
|
|
77
|
+
// returns undefined; the seam hides the difference.
|
|
78
|
+
return (db.query(sql).get(...params) ?? undefined) as T | undefined;
|
|
79
|
+
},
|
|
80
|
+
async run(sql: string, params: unknown[] = []) {
|
|
81
|
+
const r = db.query(sql).run(...params);
|
|
82
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid ?? 0 };
|
|
83
|
+
},
|
|
84
|
+
async exec(sql: string) {
|
|
85
|
+
db.exec(sql);
|
|
86
|
+
},
|
|
87
|
+
async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
|
|
88
|
+
db.exec("BEGIN");
|
|
89
|
+
try {
|
|
90
|
+
const out = await fn(self); // same connection — sqlite is single-writer
|
|
91
|
+
db.exec("COMMIT");
|
|
92
|
+
return out;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
db.exec("ROLLBACK");
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
async close() {
|
|
99
|
+
db.close();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return self;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Bun host ---------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function bunHost(): JuneHost {
|
|
108
|
+
return {
|
|
109
|
+
name: "bun",
|
|
110
|
+
serve(handler, opts) {
|
|
111
|
+
const server = Bun.serve({ port: opts.port, fetch: handler });
|
|
112
|
+
return { port: server.port ?? opts.port, stop: (force) => void server.stop(force) };
|
|
113
|
+
},
|
|
114
|
+
spawnModule(entry, args, opts) {
|
|
115
|
+
const cmd = [
|
|
116
|
+
"bun",
|
|
117
|
+
...(opts.conditions ?? []).flatMap((c) => ["--conditions", c]),
|
|
118
|
+
entry,
|
|
119
|
+
...args,
|
|
120
|
+
];
|
|
121
|
+
const child = Bun.spawn({ cmd, cwd: process.cwd(), stdout: "pipe", stderr: "pipe" });
|
|
122
|
+
return {
|
|
123
|
+
stdout: child.stdout,
|
|
124
|
+
stderrText: () => new Response(child.stderr).text(),
|
|
125
|
+
exited: child.exited,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
async openDb(path) {
|
|
129
|
+
// Non-literal specifier: only Bun has bun:sqlite, so resolve it at runtime.
|
|
130
|
+
const specifier = "bun:sqlite";
|
|
131
|
+
const { Database } = (await import(specifier)) as {
|
|
132
|
+
Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
|
|
133
|
+
};
|
|
134
|
+
return asyncSqlite(new Database(path, { create: true }));
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Node host --------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function nodeHost(): JuneHost {
|
|
142
|
+
return {
|
|
143
|
+
name: "node",
|
|
144
|
+
serve(handler, opts) {
|
|
145
|
+
const server = createServer(async (req, res) => {
|
|
146
|
+
try {
|
|
147
|
+
// Real 103 Early Hints (RFC 8297) for document requests — the browser
|
|
148
|
+
// starts fetching critical assets while we render. Node-host exclusive
|
|
149
|
+
// (Bun.serve has no interim-response API; CF reads the Link header).
|
|
150
|
+
const hints = opts.earlyHints?.();
|
|
151
|
+
if (
|
|
152
|
+
hints?.length &&
|
|
153
|
+
req.method === "GET" &&
|
|
154
|
+
(req.headers.accept ?? "").includes("text/html")
|
|
155
|
+
) {
|
|
156
|
+
res.writeEarlyHints({ link: hints });
|
|
157
|
+
}
|
|
158
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
159
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
160
|
+
const request = new Request(url, {
|
|
161
|
+
method: req.method,
|
|
162
|
+
headers: req.headers as Record<string, string>,
|
|
163
|
+
body: hasBody ? (Readable.toWeb(req) as unknown as BodyInit) : undefined,
|
|
164
|
+
// @ts-expect-error half-duplex is required for streamed request bodies
|
|
165
|
+
duplex: "half",
|
|
166
|
+
});
|
|
167
|
+
const response = await handler(request);
|
|
168
|
+
res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
|
|
169
|
+
if (response.body) {
|
|
170
|
+
Readable.fromWeb(response.body as never).pipe(res);
|
|
171
|
+
} else {
|
|
172
|
+
res.end();
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error("[june:node] request failed", err);
|
|
176
|
+
if (!res.headersSent) res.writeHead(500);
|
|
177
|
+
res.end("Internal Server Error");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
server.listen(opts.port);
|
|
181
|
+
return {
|
|
182
|
+
get port() {
|
|
183
|
+
const addr = server.address();
|
|
184
|
+
return typeof addr === "object" && addr ? addr.port : opts.port;
|
|
185
|
+
},
|
|
186
|
+
stop(force) {
|
|
187
|
+
server.close();
|
|
188
|
+
if (force) server.closeAllConnections?.();
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
spawnModule(entry, args, opts) {
|
|
193
|
+
const child = spawn(
|
|
194
|
+
process.execPath,
|
|
195
|
+
[
|
|
196
|
+
"--import",
|
|
197
|
+
"tsx", // TS-on-Node for the fallback path; `june build` emits dist JS
|
|
198
|
+
...(opts.conditions ?? []).flatMap((c) => ["--conditions", c]),
|
|
199
|
+
entry,
|
|
200
|
+
...args,
|
|
201
|
+
],
|
|
202
|
+
{ cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] },
|
|
203
|
+
);
|
|
204
|
+
const errChunks: Buffer[] = [];
|
|
205
|
+
child.stderr.on("data", (c: Buffer) => errChunks.push(c));
|
|
206
|
+
return {
|
|
207
|
+
stdout: Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
208
|
+
stderrText: async () => Buffer.concat(errChunks).toString(),
|
|
209
|
+
exited: new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0))),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
async openDb(path) {
|
|
213
|
+
const specifier = "node:sqlite"; // node-only builtin; resolve at runtime
|
|
214
|
+
const { DatabaseSync } = (await import(specifier)) as {
|
|
215
|
+
DatabaseSync: new (p: string) => {
|
|
216
|
+
prepare(sql: string): SyncStatement;
|
|
217
|
+
exec(sql: string): void;
|
|
218
|
+
close(): void;
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
const db = new DatabaseSync(path);
|
|
222
|
+
// Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
|
|
223
|
+
return asyncSqlite({
|
|
224
|
+
query: (sql) => db.prepare(sql),
|
|
225
|
+
exec: (sql) => db.exec(sql),
|
|
226
|
+
close: () => db.close(),
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// bun-types declares the global `Bun`; on Node the binding doesn't exist at
|
|
233
|
+
// runtime, which is exactly what the typeof guard checks.
|
|
234
|
+
export const host: JuneHost = typeof Bun !== "undefined" ? bunHost() : nodeHost();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @junejs/server — June's host adapters + dev server. Host-coupled (`node:*`
|
|
2
|
+
// allowed); it composes ON TOP of the pure `@junejs/core` contract layer.
|
|
3
|
+
|
|
4
|
+
export { host, type JuneHost, type JuneDb, type RunResult, type ServeHandle, type SpawnedModule } from "./host";
|
|
5
|
+
export { sqlite, d1, type D1Database } from "./db";
|
|
6
|
+
export { memoryKv, redisKv } from "./kv";
|
|
7
|
+
export { localBlob, r2, type R2Bucket } from "./blob";
|
|
8
|
+
export { memoizeResources } from "./resources";
|
|
9
|
+
export { loadJuneConfig } from "./config-loader";
|
|
10
|
+
export { collection, entry, type ContentEntry } from "./content";
|
|
11
|
+
export {
|
|
12
|
+
listRoutes,
|
|
13
|
+
matchRoute,
|
|
14
|
+
matchRouteTree,
|
|
15
|
+
resolveNotFound,
|
|
16
|
+
type RouteMatch,
|
|
17
|
+
type RouteTreeMatch,
|
|
18
|
+
type SegmentMatch,
|
|
19
|
+
type MatchOptions,
|
|
20
|
+
} from "./router";
|
|
21
|
+
export { installAsyncContext } from "./instrumentation";
|
|
22
|
+
export { negotiate, type Negotiated } from "./negotiate";
|
|
23
|
+
export {
|
|
24
|
+
createPipeline,
|
|
25
|
+
type Pipeline,
|
|
26
|
+
type PipelineConfig,
|
|
27
|
+
type RouteResolver,
|
|
28
|
+
type Resolved,
|
|
29
|
+
type LayoutComponent,
|
|
30
|
+
} from "./pipeline";
|
|
31
|
+
export { createApp, type JuneApp, type CreateAppOptions } from "./app";
|
|
32
|
+
export { createWorker, type WorkerManifest } from "./worker";
|
|
33
|
+
export {
|
|
34
|
+
juneBuild,
|
|
35
|
+
buildManifest,
|
|
36
|
+
scanRoutes,
|
|
37
|
+
generateContent,
|
|
38
|
+
freezeConfig,
|
|
39
|
+
type BuildResult,
|
|
40
|
+
} from "./build";
|
|
41
|
+
export { juneDeploy, type DeployResult } from "./deploy";
|
|
42
|
+
export { startDevServer, type DevServer, type DevServerOptions } from "./dev";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The host side of request tracing. @junejs/core's instrumentation is pure and
|
|
2
|
+
// host-free — it exposes `installTraceContext(provider)` and otherwise no-ops.
|
|
3
|
+
// This module supplies the provider: node:async_hooks' AsyncLocalStorage,
|
|
4
|
+
// loaded LAZILY through a non-literal specifier so no bundler resolves `node:*`
|
|
5
|
+
// (workerd assets-mode registers chunks raw — a static import would break
|
|
6
|
+
// module registration even on a path that never runs there). See rebuild-plan
|
|
7
|
+
// reminders #1 and #4.
|
|
8
|
+
//
|
|
9
|
+
// Hosts that lack async_hooks simply never call this; @junejs/core then runs
|
|
10
|
+
// untraced and every recorder degrades to a no-op — requests still serve.
|
|
11
|
+
|
|
12
|
+
import { installTraceContext, type RequestTrace } from "@junejs/core/instrumentation";
|
|
13
|
+
|
|
14
|
+
let installed = false;
|
|
15
|
+
|
|
16
|
+
export async function installAsyncContext(): Promise<boolean> {
|
|
17
|
+
if (installed) return true;
|
|
18
|
+
try {
|
|
19
|
+
const specifier = "node:async_hooks";
|
|
20
|
+
const mod = (await import(specifier)) as {
|
|
21
|
+
AsyncLocalStorage: new () => {
|
|
22
|
+
getStore(): RequestTrace | undefined;
|
|
23
|
+
run<R>(store: RequestTrace, fn: () => R): R;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
installTraceContext(new mod.AsyncLocalStorage());
|
|
27
|
+
installed = true;
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
// No async context on this host — tracing stays disabled.
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/kv.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// `kv` resource adapters. The kv resource IS the cache (docs/data-layer-boundary
|
|
2
|
+
// .md: "reframe cache as the kv resource — it already is one"): these wrap
|
|
3
|
+
// @junejs/core/cache's CacheStore (memory / redis) as the simpler JuneKv contract,
|
|
4
|
+
// so there is ONE key-value system, surfaced both as cache() and as ctx.kv.
|
|
5
|
+
|
|
6
|
+
import { memory, redis, type CacheStore } from "@junejs/core/cache";
|
|
7
|
+
import type { JuneKv, KvFactory } from "@junejs/core/resources";
|
|
8
|
+
|
|
9
|
+
function kvOver(store: CacheStore): JuneKv {
|
|
10
|
+
return {
|
|
11
|
+
async get<T>(key: string) {
|
|
12
|
+
const entry = await store.get(key);
|
|
13
|
+
return (entry?.value ?? null) as T | null;
|
|
14
|
+
},
|
|
15
|
+
async put(key: string, value: unknown, opts?: { ttl?: number }) {
|
|
16
|
+
const expiresAt = opts?.ttl ? Date.now() + opts.ttl * 1000 : null;
|
|
17
|
+
await store.set(key, { value, expiresAt, staleUntil: expiresAt, tags: [] });
|
|
18
|
+
},
|
|
19
|
+
async delete(key: string) {
|
|
20
|
+
await store.delete(key);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// In-memory kv — the zero-config dev default (same store class cache() uses).
|
|
26
|
+
export function memoryKv(): KvFactory {
|
|
27
|
+
return { kind: "memory", open: async () => kvOver(await memory().connect()) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Redis-backed kv (Bun's native client) — and the shape a Cloudflare KV adapter
|
|
31
|
+
// slots into (same JuneKv contract).
|
|
32
|
+
export function redisKv(opts: { url: string }): KvFactory {
|
|
33
|
+
return { kind: "redis", open: async () => kvOver(await redis(opts).connect()) };
|
|
34
|
+
}
|
package/src/negotiate.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Content negotiation — turn a request into (RenderTarget, clean pathname,
|
|
2
|
+
// speculative?). Pure and host-free so it is trivially testable; the dev server
|
|
3
|
+
// and the built worker both route through it, so the negotiation can't drift.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: an explicit URL extension (`/users.json`) wins over the Accept
|
|
6
|
+
// header — a link is unambiguous; Accept is a hint. The clean pathname (with
|
|
7
|
+
// the projection extension stripped) is what the router matches.
|
|
8
|
+
|
|
9
|
+
import type { RenderTarget } from "@junejs/core/route";
|
|
10
|
+
|
|
11
|
+
const EXT_TARGET: Record<string, RenderTarget> = {
|
|
12
|
+
".json": "json",
|
|
13
|
+
".agent": "agent",
|
|
14
|
+
".md": "md",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ACCEPT_TARGET: Array<[test: RegExp, target: RenderTarget]> = [
|
|
18
|
+
[/application\/vnd\.june-agent\+json/, "agent"],
|
|
19
|
+
[/text\/markdown/, "md"],
|
|
20
|
+
[/application\/json/, "json"],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export type Negotiated = {
|
|
24
|
+
target: RenderTarget;
|
|
25
|
+
pathname: string; // projection extension stripped
|
|
26
|
+
speculative: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function negotiate(url: URL, request: Request): Negotiated {
|
|
30
|
+
let pathname = url.pathname;
|
|
31
|
+
let target: RenderTarget | null = null;
|
|
32
|
+
|
|
33
|
+
for (const [ext, t] of Object.entries(EXT_TARGET)) {
|
|
34
|
+
if (pathname.endsWith(ext)) {
|
|
35
|
+
target = t;
|
|
36
|
+
pathname = pathname.slice(0, -ext.length) || "/";
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!target) {
|
|
42
|
+
const accept = request.headers.get("accept") ?? "";
|
|
43
|
+
for (const [re, t] of ACCEPT_TARGET) {
|
|
44
|
+
if (re.test(accept)) {
|
|
45
|
+
target = t;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A speculative request (Sec-Purpose: prefetch / prerender) may never be seen
|
|
52
|
+
// — load()s read it to skip side effects (analytics, rate limits, counters).
|
|
53
|
+
const purpose = request.headers.get("sec-purpose") ?? "";
|
|
54
|
+
const speculative = /prefetch|prerender/.test(purpose);
|
|
55
|
+
|
|
56
|
+
return { target: target ?? "view", pathname, speculative };
|
|
57
|
+
}
|