@junejs/server 0.0.6 → 0.0.8
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 +2 -2
- package/src/build.ts +10 -1
- package/src/db.ts +17 -4
- package/src/dev-reload.ts +16 -3
- package/src/host.ts +4 -1
- package/src/pipeline.ts +26 -0
- package/src/router.ts +19 -6
- package/src/worker.ts +45 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@junejs/core": "0.0.
|
|
32
|
+
"@junejs/core": "0.0.8",
|
|
33
33
|
"marked": "^18.0.5",
|
|
34
34
|
"rolldown": "^1.1.0"
|
|
35
35
|
},
|
package/src/build.ts
CHANGED
|
@@ -341,15 +341,24 @@ ${chains.join("\n")}
|
|
|
341
341
|
const pkg = existsSync(pkgPath)
|
|
342
342
|
? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
|
|
343
343
|
: {};
|
|
344
|
+
const deployCfg = (await loadJuneConfig(appRoot)).deploy;
|
|
344
345
|
await writeFile(
|
|
345
346
|
join(outDir, "wrangler.jsonc"),
|
|
346
347
|
JSON.stringify(
|
|
347
348
|
{
|
|
348
|
-
name:
|
|
349
|
+
name:
|
|
350
|
+
deployCfg?.name ??
|
|
351
|
+
(pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
|
|
349
352
|
main: "./worker.js",
|
|
350
353
|
compatibility_date: "2025-01-01",
|
|
351
354
|
compatibility_flags: ["nodejs_compat"],
|
|
352
355
|
...(hasAssets ? { assets: { directory: "./assets" } } : {}),
|
|
356
|
+
// config deploy.domain → a Workers custom domain; without it the
|
|
357
|
+
// generated file regenerating on every build would silently drop a
|
|
358
|
+
// hand-attached domain.
|
|
359
|
+
...(deployCfg?.domain
|
|
360
|
+
? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
|
|
361
|
+
: {}),
|
|
353
362
|
},
|
|
354
363
|
null,
|
|
355
364
|
2,
|
package/src/db.ts
CHANGED
|
@@ -3,15 +3,28 @@
|
|
|
3
3
|
// by the host, injected onto RouteContext as `ctx.db`. The framework depends on
|
|
4
4
|
// the JuneDb contract; these adapters (and Juno on top) are swappable.
|
|
5
5
|
|
|
6
|
+
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
|
|
6
9
|
import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
|
|
7
10
|
|
|
8
11
|
import { host } from "./host";
|
|
9
12
|
|
|
10
|
-
// Local SQLite — the zero-config dev default
|
|
11
|
-
// on
|
|
13
|
+
// Local SQLite — the zero-config dev default. A persistent FILE, not :memory:,
|
|
14
|
+
// on purpose: `june dev` restarts the process on every save (the watch
|
|
15
|
+
// supervisor), so an in-memory default would evaporate your dev data at each
|
|
16
|
+
// edit. A file also makes the dev db inspectable with the sqlite3 every
|
|
17
|
+
// machine already has (`sqlite3 .june/dev.sqlite`). Pass ":memory:" explicitly
|
|
18
|
+
// for ephemerality.
|
|
12
19
|
export function sqlite(opts: { path?: string } = {}): DbFactory {
|
|
13
|
-
const path = opts.path ?? "
|
|
14
|
-
return {
|
|
20
|
+
const path = opts.path ?? ".june/dev.sqlite";
|
|
21
|
+
return {
|
|
22
|
+
kind: "sqlite",
|
|
23
|
+
open: () => {
|
|
24
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
return host.openDb(path);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
// --- D1 (Cloudflare) — the third openDb impl (rebuild-plan Phase 5) ----------
|
package/src/dev-reload.ts
CHANGED
|
@@ -37,11 +37,24 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → location.re
|
|
|
37
37
|
`;
|
|
38
38
|
|
|
39
39
|
function devEvents(): Response {
|
|
40
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
40
41
|
const stream = new ReadableStream<Uint8Array>({
|
|
41
42
|
start(controller) {
|
|
42
|
-
// One greeting so the browser fires `open
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// One greeting so the browser fires `open`, then a comment heartbeat:
|
|
44
|
+
// a silent stream gets culled by idle timeouts (hosts, proxies), and a
|
|
45
|
+
// culled stream reads as a restart to the client — which reloads.
|
|
46
|
+
const enc = new TextEncoder();
|
|
47
|
+
controller.enqueue(enc.encode("retry: 300\n\ndata: connected\n\n"));
|
|
48
|
+
heartbeat = setInterval(() => {
|
|
49
|
+
try {
|
|
50
|
+
controller.enqueue(enc.encode(":hb\n\n"));
|
|
51
|
+
} catch {
|
|
52
|
+
clearInterval(heartbeat);
|
|
53
|
+
}
|
|
54
|
+
}, 20_000);
|
|
55
|
+
},
|
|
56
|
+
cancel() {
|
|
57
|
+
clearInterval(heartbeat);
|
|
45
58
|
},
|
|
46
59
|
});
|
|
47
60
|
return new Response(stream, {
|
package/src/host.ts
CHANGED
|
@@ -108,7 +108,10 @@ function bunHost(): JuneHost {
|
|
|
108
108
|
return {
|
|
109
109
|
name: "bun",
|
|
110
110
|
serve(handler, opts) {
|
|
111
|
-
|
|
111
|
+
// idleTimeout 0: Bun's 10s default kills quiet long-lived responses —
|
|
112
|
+
// the dev live-reload SSE most visibly (the browser then reconnects,
|
|
113
|
+
// reads it as a server restart, and reloads the page every 10s).
|
|
114
|
+
const server = Bun.serve({ port: opts.port, fetch: handler, idleTimeout: 0 });
|
|
112
115
|
return { port: server.port ?? opts.port, stop: (force) => void server.stop(force) };
|
|
113
116
|
},
|
|
114
117
|
spawnModule(entry, args, opts) {
|
package/src/pipeline.ts
CHANGED
|
@@ -95,6 +95,23 @@ function text(body: string, contentType: string, init?: ResponseInit) {
|
|
|
95
95
|
return new Response(body, { ...init, headers });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The default favicon: the site name's first character in a rounded square —
|
|
99
|
+
// a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
|
|
100
|
+
// names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
|
|
101
|
+
// (browsers respect the svg content-type), so no June app 404s its icon.
|
|
102
|
+
function letterFavicon(siteName: string | undefined): Response {
|
|
103
|
+
const first = (siteName ?? "").trim().charAt(0) || "•";
|
|
104
|
+
const letter = first.toUpperCase().replace(/[<>&"']/g, "");
|
|
105
|
+
return text(
|
|
106
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
|
|
107
|
+
`<rect width="64" height="64" rx="12" fill="#1d1d1f"/>` +
|
|
108
|
+
`<text x="32" y="32" dy=".36em" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="34" font-weight="600" fill="#fbfbf8">${letter || "•"}</text>` +
|
|
109
|
+
`</svg>\n`,
|
|
110
|
+
"image/svg+xml",
|
|
111
|
+
{ headers: { "cache-control": "public, max-age=86400" } },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
99
116
|
const { docConfig, agent } = cfg;
|
|
100
117
|
const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
|
|
@@ -219,6 +236,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
219
236
|
if (out) return out;
|
|
220
237
|
}
|
|
221
238
|
|
|
239
|
+
// --- default favicon (after extra so an app can override it) ----------
|
|
240
|
+
if (
|
|
241
|
+
request.method === "GET" &&
|
|
242
|
+
!docConfig.site.icon &&
|
|
243
|
+
(url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")
|
|
244
|
+
) {
|
|
245
|
+
return letterFavicon(docConfig.site.name);
|
|
246
|
+
}
|
|
247
|
+
|
|
222
248
|
// --- routes ----------------------------------------------------------
|
|
223
249
|
const { target, pathname, speculative } = negotiate(url, request);
|
|
224
250
|
const resolved = await cfg.resolve(pathname);
|
package/src/router.ts
CHANGED
|
@@ -67,8 +67,12 @@ function isSpecialFile(file: string) {
|
|
|
67
67
|
|
|
68
68
|
const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
|
|
69
69
|
const isParamDir = (name: string) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
|
|
70
|
+
// [[slug]] / [[...slug]] — match like their required forms, and ALSO match the
|
|
71
|
+
// segment being absent (the param is then simply missing from ctx.params).
|
|
72
|
+
const isOptionalDir = (name: string) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
|
|
73
|
+
const isOptionalCatchAllDir = (name: string) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
|
|
70
74
|
const isCatchAllDir = (name: string) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
|
|
71
|
-
const paramName = (name: string) => name.replace(/^\[(\.\.\.)?|\]
|
|
75
|
+
const paramName = (name: string) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
|
|
72
76
|
|
|
73
77
|
type DirEntry = { name: string; dir: boolean };
|
|
74
78
|
|
|
@@ -164,7 +168,16 @@ export async function matchRouteTree(
|
|
|
164
168
|
if (hit) return hit;
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
if (rest.length === 0)
|
|
171
|
+
if (rest.length === 0) {
|
|
172
|
+
// Optional segments match ABSENCE too: descend without consuming and
|
|
173
|
+
// without setting the param.
|
|
174
|
+
for (const e of entries) {
|
|
175
|
+
if (!e.dir || !(isOptionalDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
|
|
176
|
+
const hit = await descend(join(dir, e.name), [], params, segments);
|
|
177
|
+
if (hit) return hit;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
168
181
|
const [head, ...tail] = rest as [string, ...string[]];
|
|
169
182
|
|
|
170
183
|
// 1) exact static dir
|
|
@@ -173,9 +186,9 @@ export async function matchRouteTree(
|
|
|
173
186
|
const hit = await descend(join(dir, head), tail, params, segments);
|
|
174
187
|
if (hit) return hit;
|
|
175
188
|
}
|
|
176
|
-
// 2) [param] dirs
|
|
189
|
+
// 2) [param] and [[param]] dirs consume one segment
|
|
177
190
|
for (const e of entries) {
|
|
178
|
-
if (!e.dir || !isParamDir(e.name)) continue;
|
|
191
|
+
if (!e.dir || !(isParamDir(e.name) || isOptionalDir(e.name))) continue;
|
|
179
192
|
const hit = await descend(
|
|
180
193
|
join(dir, e.name),
|
|
181
194
|
tail,
|
|
@@ -184,9 +197,9 @@ export async function matchRouteTree(
|
|
|
184
197
|
);
|
|
185
198
|
if (hit) return hit;
|
|
186
199
|
}
|
|
187
|
-
// 3) [...catchAll] dirs consume everything remaining
|
|
200
|
+
// 3) [...catchAll] and [[...catchAll]] dirs consume everything remaining
|
|
188
201
|
for (const e of entries) {
|
|
189
|
-
if (!e.dir || !isCatchAllDir(e.name)) continue;
|
|
202
|
+
if (!e.dir || !(isCatchAllDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
|
|
190
203
|
const hit = await descend(
|
|
191
204
|
join(dir, e.name),
|
|
192
205
|
[],
|
package/src/worker.ts
CHANGED
|
@@ -41,26 +41,45 @@ export type WorkerManifest = {
|
|
|
41
41
|
|
|
42
42
|
type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
|
|
43
43
|
|
|
44
|
-
// "/posts/[slug]" | "/docs/[...path]" → matcher
|
|
44
|
+
// "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
|
|
45
|
+
// segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
|
|
46
|
+
// a pattern of ONLY optional segments must also match the bare "/".
|
|
45
47
|
function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
|
|
46
48
|
const names: string[] = [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
let source = "";
|
|
50
|
+
let allOptional = true;
|
|
51
|
+
for (const seg of pattern.split("/").filter(Boolean)) {
|
|
52
|
+
const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
|
|
53
|
+
if (oca) {
|
|
54
|
+
names.push(oca[1]!);
|
|
55
|
+
source += "(?:/(.+))?";
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
|
|
59
|
+
if (ca) {
|
|
60
|
+
names.push(ca[1]!);
|
|
61
|
+
source += "/(.+)";
|
|
62
|
+
allOptional = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const op = seg.match(/^\[\[(\w+)\]\]$/);
|
|
66
|
+
if (op) {
|
|
67
|
+
names.push(op[1]!);
|
|
68
|
+
source += "(?:/([^/]+))?";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const p = seg.match(/^\[(\w+)\]$/);
|
|
72
|
+
if (p) {
|
|
73
|
+
names.push(p[1]!);
|
|
74
|
+
source += "/([^/]+)";
|
|
75
|
+
allOptional = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
|
|
79
|
+
allOptional = false;
|
|
80
|
+
}
|
|
81
|
+
if (source === "") return { regex: /^\/$/, names };
|
|
82
|
+
return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
export function createWorker(manifest: WorkerManifest): { fetch(request: Request): Promise<Response> } {
|
|
@@ -91,7 +110,14 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
|
|
|
91
110
|
for (const d of dynamic) {
|
|
92
111
|
const m = pathname.match(d.regex);
|
|
93
112
|
if (m) {
|
|
94
|
-
|
|
113
|
+
// Optional segments leave their capture undefined — the param is
|
|
114
|
+
// then absent, matching the dev matcher's semantics.
|
|
115
|
+
const params = Object.fromEntries(
|
|
116
|
+
d.names.flatMap((n, i) => {
|
|
117
|
+
const v = m[i + 1];
|
|
118
|
+
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
95
121
|
return { def: d.def, params, chain: chainFor(d.pattern) };
|
|
96
122
|
}
|
|
97
123
|
}
|