smaoog 0.0.1
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 +102 -0
- package/package.json +50 -0
- package/src/assets.js +118 -0
- package/src/build.js +216 -0
- package/src/cli.js +58 -0
- package/src/client/index.d.ts +6 -0
- package/src/client/index.js +12 -0
- package/src/client/navigation.js +209 -0
- package/src/client/runtime.js +47 -0
- package/src/client/store.js +31 -0
- package/src/conventions.js +47 -0
- package/src/dev.js +84 -0
- package/src/dispatch.js +75 -0
- package/src/errors.js +11 -0
- package/src/form.js +121 -0
- package/src/index.d.ts +120 -0
- package/src/index.js +95 -0
- package/src/link.js +22 -0
- package/src/meta.js +87 -0
- package/src/protocol.js +14 -0
- package/src/render.js +55 -0
- package/src/request.js +133 -0
- package/src/response.js +256 -0
- package/src/router.js +497 -0
- package/src/routes.js +175 -0
- package/src/serialize.js +17 -0
- package/src/server-only.js +122 -0
- package/src/server.js +42 -0
- package/src/start.js +60 -0
- package/src/tailwind.js +20 -0
- package/src/vite.js +69 -0
package/src/router.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { extname, resolve, sep } from "node:path";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { ASSET_PREFIX, createClientContext } from "./assets.js";
|
|
6
|
+
import { runHandler } from "./dispatch.js";
|
|
7
|
+
import { matchRoute } from "./routes.js";
|
|
8
|
+
import { createRenderIntent, sendJson, sendResult } from "./response.js";
|
|
9
|
+
import { SUBMIT_HEADER } from "./protocol.js";
|
|
10
|
+
import { RESERVED_PREFIX } from "./server.js";
|
|
11
|
+
|
|
12
|
+
// The client navigation endpoint. A `<Link>` click fetches `?to=/target` here;
|
|
13
|
+
// the framework runs the target route's GET handler and serializes its
|
|
14
|
+
// RenderIntent as a JSON page-swap payload (see sendResult's nav mode).
|
|
15
|
+
const NAV_PATH = "/_smaoog/nav";
|
|
16
|
+
|
|
17
|
+
// HTTP methods a route module may export as named handlers, in canonical order.
|
|
18
|
+
// The order is reused for the Allow header so it is deterministic.
|
|
19
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
20
|
+
|
|
21
|
+
const renderPage = (req, res) => res.render();
|
|
22
|
+
|
|
23
|
+
// Pathname without the query string. A dummy origin lets the URL parser handle
|
|
24
|
+
// path-only request targets like "/about?x=1".
|
|
25
|
+
function pathnameOf(url) {
|
|
26
|
+
return new URL(url, "http://localhost").pathname;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Requests Vite owns in dev: its virtual/internal endpoints, pre-bundled deps,
|
|
30
|
+
// and transformed source modules. CSS under /src stays on the framework's own
|
|
31
|
+
// path (traversal-safe), so only non-CSS /src files are delegated here.
|
|
32
|
+
function isViteModuleRequest(path) {
|
|
33
|
+
if (path.startsWith("/@") || path.startsWith("/node_modules/")) return true;
|
|
34
|
+
if (path.startsWith("/src/")) return !path.endsWith(".css");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasPage(mod) {
|
|
39
|
+
return typeof mod.default === "function";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function DefaultNotFoundPage() {
|
|
43
|
+
return React.createElement("h1", null, "Not Found");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `message` is only populated in dev (see errorProps); in production it is
|
|
47
|
+
// omitted so internal error detail never reaches the client.
|
|
48
|
+
function DefaultErrorPage({ message }) {
|
|
49
|
+
return React.createElement(
|
|
50
|
+
"div",
|
|
51
|
+
null,
|
|
52
|
+
React.createElement("h1", null, "Internal Server Error"),
|
|
53
|
+
message ? React.createElement("pre", null, message) : null,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve the route module's final method handler set. A lowercase export of a
|
|
58
|
+
// known method (`get`, `post`, ...) is almost always a typo for the uppercase
|
|
59
|
+
// name, so it is a clear error rather than a silently dead handler. Page routes
|
|
60
|
+
// without an explicit GET get an implicit render GET.
|
|
61
|
+
function resolveHandlers(mod) {
|
|
62
|
+
const handlers = {};
|
|
63
|
+
for (const method of HTTP_METHODS) {
|
|
64
|
+
const lower = method.toLowerCase();
|
|
65
|
+
if (typeof mod[lower] === "function") {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Route exports "${lower}" but HTTP method handlers must be uppercase ("${method}").`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (typeof mod[method] === "function") {
|
|
71
|
+
handlers[method] = mod[method];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!handlers.GET && hasPage(mod)) {
|
|
76
|
+
handlers.GET = renderPage;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return handlers;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The Allow header value for a route: its declared methods plus the ones the
|
|
83
|
+
// framework derives — HEAD (from GET) and the always-available OPTIONS.
|
|
84
|
+
function allowHeader(handlers) {
|
|
85
|
+
const methods = HTTP_METHODS.filter((method) => handlers[method]);
|
|
86
|
+
if (handlers.GET) methods.push("HEAD");
|
|
87
|
+
methods.push("OPTIONS");
|
|
88
|
+
return methods.join(", ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sendText(nodeRes, status, body, headers = {}) {
|
|
92
|
+
nodeRes.statusCode = status;
|
|
93
|
+
nodeRes.setHeader("content-type", "text/plain; charset=utf-8");
|
|
94
|
+
for (const [key, value] of Object.entries(headers)) nodeRes.setHeader(key, value);
|
|
95
|
+
nodeRes.end(body);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function contentType(file) {
|
|
99
|
+
switch (extname(file).toLowerCase()) {
|
|
100
|
+
case ".css":
|
|
101
|
+
return "text/css; charset=utf-8";
|
|
102
|
+
case ".html":
|
|
103
|
+
return "text/html; charset=utf-8";
|
|
104
|
+
case ".js":
|
|
105
|
+
return "text/javascript; charset=utf-8";
|
|
106
|
+
case ".json":
|
|
107
|
+
return "application/json; charset=utf-8";
|
|
108
|
+
case ".svg":
|
|
109
|
+
return "image/svg+xml";
|
|
110
|
+
case ".ico":
|
|
111
|
+
return "image/x-icon";
|
|
112
|
+
case ".png":
|
|
113
|
+
return "image/png";
|
|
114
|
+
case ".jpg":
|
|
115
|
+
case ".jpeg":
|
|
116
|
+
return "image/jpeg";
|
|
117
|
+
case ".txt":
|
|
118
|
+
return "text/plain; charset=utf-8";
|
|
119
|
+
default:
|
|
120
|
+
return "application/octet-stream";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function safeFilePath(root, pathname) {
|
|
125
|
+
let decoded;
|
|
126
|
+
try {
|
|
127
|
+
decoded = decodeURIComponent(pathname);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const file = resolve(root, `.${decoded}`);
|
|
132
|
+
const boundary = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
133
|
+
if (file !== root && !file.startsWith(boundary)) return null;
|
|
134
|
+
return file;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Whether `pathname` resolves to an existing regular file under `root` (and
|
|
138
|
+
// stays within it). Used to decide if a stylesheet is worth transforming before
|
|
139
|
+
// handing the path to Vite, which throws for a missing module.
|
|
140
|
+
async function isFile(root, pathname) {
|
|
141
|
+
const file = safeFilePath(root, pathname);
|
|
142
|
+
if (!file) return false;
|
|
143
|
+
try {
|
|
144
|
+
return (await stat(file)).isFile();
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function sendFile(root, pathname, nodeReq, nodeRes) {
|
|
151
|
+
const method = (nodeReq.method ?? "GET").toUpperCase();
|
|
152
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
153
|
+
|
|
154
|
+
const file = safeFilePath(root, pathname);
|
|
155
|
+
if (!file) return false;
|
|
156
|
+
|
|
157
|
+
let info;
|
|
158
|
+
try {
|
|
159
|
+
info = await stat(file);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile()) return false;
|
|
164
|
+
|
|
165
|
+
nodeRes.statusCode = 200;
|
|
166
|
+
nodeRes.setHeader("content-type", contentType(file));
|
|
167
|
+
nodeRes.setHeader("content-length", String(info.size));
|
|
168
|
+
|
|
169
|
+
if (method === "HEAD") {
|
|
170
|
+
nodeRes.end();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await new Promise((resolveStream, rejectStream) => {
|
|
175
|
+
const stream = createReadStream(file);
|
|
176
|
+
stream.on("error", rejectStream);
|
|
177
|
+
nodeRes.on("error", rejectStream);
|
|
178
|
+
nodeRes.on("finish", resolveStream);
|
|
179
|
+
stream.pipe(nodeRes);
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Build the framework's request handler. It owns the reserved prefix, route
|
|
185
|
+
// matching, HTTP method matching, error pages, and static file serving — but
|
|
186
|
+
// not how modules are loaded. `loader` abstracts that:
|
|
187
|
+
// - loader.route(route) -> the route's module
|
|
188
|
+
// - loader.document() -> the document module, or null
|
|
189
|
+
// - loader.errorPage(kind) -> the user _404/_500 module, or null
|
|
190
|
+
// In dev that's Vite SSR loading; in production it's importing built modules.
|
|
191
|
+
// `srcRoot` enables the dev-only source-CSS path when provided. `viteMiddlewares`
|
|
192
|
+
// (dev only) serves transformed browser modules and Vite internals.
|
|
193
|
+
export function createRequestHandler({
|
|
194
|
+
routes,
|
|
195
|
+
loader,
|
|
196
|
+
publicRoot,
|
|
197
|
+
srcRoot = null,
|
|
198
|
+
assetsRoot = null,
|
|
199
|
+
clientAssets = null,
|
|
200
|
+
viteMiddlewares = null,
|
|
201
|
+
transformStylesheet = null,
|
|
202
|
+
dev = false,
|
|
203
|
+
options = {},
|
|
204
|
+
}) {
|
|
205
|
+
// Resolves dev/prod URLs for stylesheets, the client entry, and page hydration
|
|
206
|
+
// modules; the Document helpers read it through the render context.
|
|
207
|
+
const clientContext = createClientContext({ dev, manifest: clientAssets });
|
|
208
|
+
// Props for an error page. In dev the raw message is shown to aid debugging;
|
|
209
|
+
// in production it is kept server-side (logged) and never sent to the client.
|
|
210
|
+
function errorProps(err) {
|
|
211
|
+
if (dev) return { message: err?.message ?? String(err) };
|
|
212
|
+
console.error("[smaoog] route error:", err);
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
// Load a user error page, or fall back to the framework default. The id is the
|
|
216
|
+
// convention name so meta/hydration have a stable identity in both cases.
|
|
217
|
+
async function loadErrorPage(kind) {
|
|
218
|
+
const id = kind === "notFound" ? "_404" : "_500";
|
|
219
|
+
const mod = await loader.errorPage(kind);
|
|
220
|
+
if (!mod) {
|
|
221
|
+
return {
|
|
222
|
+
id,
|
|
223
|
+
mod: { default: kind === "notFound" ? DefaultNotFoundPage : DefaultErrorPage },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!hasPage(mod)) {
|
|
227
|
+
throw new Error(`Error page "${id}" must have a default export.`);
|
|
228
|
+
}
|
|
229
|
+
return { id, mod };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function renderErrorPage(
|
|
233
|
+
nodeRes,
|
|
234
|
+
kind,
|
|
235
|
+
{ status, props = {}, headers = {}, head = false, mode = "html" },
|
|
236
|
+
) {
|
|
237
|
+
try {
|
|
238
|
+
const page = await loadErrorPage(kind);
|
|
239
|
+
// Only the full-load (html) path wraps the page in a document; enhanced
|
|
240
|
+
// requests serialize the error page as a JSON render envelope.
|
|
241
|
+
const documentMod = mode === "html" ? await loader.document() : null;
|
|
242
|
+
sendResult(
|
|
243
|
+
nodeRes,
|
|
244
|
+
createRenderIntent({
|
|
245
|
+
status,
|
|
246
|
+
headers,
|
|
247
|
+
props,
|
|
248
|
+
routeId: page.id,
|
|
249
|
+
Page: page.mod.default,
|
|
250
|
+
Document: documentMod?.default,
|
|
251
|
+
routeModule: page.mod,
|
|
252
|
+
client: clientContext,
|
|
253
|
+
}),
|
|
254
|
+
{ head, mode },
|
|
255
|
+
);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// A broken notFound page falls back to the error page once; a broken error
|
|
258
|
+
// page falls back to plain text so we never recurse forever.
|
|
259
|
+
if (kind !== "error" && !nodeRes.headersSent) {
|
|
260
|
+
await renderErrorPage(nodeRes, "error", {
|
|
261
|
+
status: 500,
|
|
262
|
+
props: errorProps(err),
|
|
263
|
+
head,
|
|
264
|
+
mode,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!nodeRes.headersSent) {
|
|
270
|
+
if (!dev) console.error("[smaoog] error page failed:", err);
|
|
271
|
+
const detail = dev ? `\n\n${err?.message ?? err}` : "";
|
|
272
|
+
sendText(nodeRes, 500, `Internal Server Error${detail}`);
|
|
273
|
+
} else if (!nodeRes.writableEnded) {
|
|
274
|
+
nodeRes.end();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Serve a matched route. Shared by ordinary requests, the navigation endpoint,
|
|
280
|
+
// and <Form> mutations, so all three run the exact same matching, method
|
|
281
|
+
// resolution, and handler dispatch — the only difference is how the result is
|
|
282
|
+
// serialized (`mode`). `reqUrl` is the URL the handler should see; for nav it
|
|
283
|
+
// is the target URL, not `/_smaoog/nav`.
|
|
284
|
+
async function dispatchMatch(nodeReq, nodeRes, { match, method, head, mode, reqUrl }) {
|
|
285
|
+
const mod = await loader.route(match.route);
|
|
286
|
+
// The document only wraps rendered HTML, so endpoint-only routes (no default
|
|
287
|
+
// export) — and enhanced requests, which never render a document — skip it.
|
|
288
|
+
const documentMod = mode === "html" && hasPage(mod) ? await loader.document() : null;
|
|
289
|
+
const handlers = resolveHandlers(mod);
|
|
290
|
+
const allow = allowHeader(handlers);
|
|
291
|
+
|
|
292
|
+
// OPTIONS is answered by the framework: 204 with the Allow header.
|
|
293
|
+
if (method === "OPTIONS") {
|
|
294
|
+
nodeRes.statusCode = 204;
|
|
295
|
+
nodeRes.setHeader("Allow", allow);
|
|
296
|
+
nodeRes.end();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// HEAD runs the GET handler but the body is stripped on the way out.
|
|
301
|
+
const handler = handlers[head ? "GET" : method];
|
|
302
|
+
|
|
303
|
+
// Known route, unsupported method. A navigation request can't swap a page
|
|
304
|
+
// it has no GET for, so it falls back to a full load; a normal request gets
|
|
305
|
+
// the usual 405 with the Allow header.
|
|
306
|
+
if (!handler) {
|
|
307
|
+
if (mode === "nav") {
|
|
308
|
+
sendJson(nodeRes, 200, { kind: "reload" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
sendText(nodeRes, 405, "Method Not Allowed", { Allow: allow });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await runHandler(handler, nodeReq, nodeRes, {
|
|
316
|
+
...options,
|
|
317
|
+
params: match.params,
|
|
318
|
+
url: reqUrl,
|
|
319
|
+
head,
|
|
320
|
+
mode,
|
|
321
|
+
// Rendering is only allowed when the route has a page (default export).
|
|
322
|
+
canRender: hasPage(mod),
|
|
323
|
+
renderInfo: {
|
|
324
|
+
routeId: match.route.id,
|
|
325
|
+
Page: mod.default,
|
|
326
|
+
routeModule: mod,
|
|
327
|
+
Document: documentMod?.default,
|
|
328
|
+
client: clientContext,
|
|
329
|
+
},
|
|
330
|
+
notFoundHandler: (result, opts) =>
|
|
331
|
+
renderErrorPage(nodeRes, "notFound", {
|
|
332
|
+
status: result.status,
|
|
333
|
+
props: result.props,
|
|
334
|
+
headers: result.headers,
|
|
335
|
+
head: opts.head,
|
|
336
|
+
mode,
|
|
337
|
+
}),
|
|
338
|
+
errorHandler: (err, opts) =>
|
|
339
|
+
renderErrorPage(nodeRes, "error", {
|
|
340
|
+
status: 500,
|
|
341
|
+
props: errorProps(err),
|
|
342
|
+
head: opts.head,
|
|
343
|
+
mode,
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The /_smaoog/nav endpoint: render the target route for client navigation.
|
|
349
|
+
// It does not implement a second router — it parses the target URL, runs the
|
|
350
|
+
// normal matcher, and dispatches the target's GET handler with `nav` mode so
|
|
351
|
+
// the RenderIntent is serialized as a JSON page-swap payload.
|
|
352
|
+
async function handleNav(nodeReq, nodeRes) {
|
|
353
|
+
const to = new URL(nodeReq.url, "http://localhost").searchParams.get("to");
|
|
354
|
+
if (!to) {
|
|
355
|
+
sendJson(nodeRes, 400, { kind: "error", message: "Missing 'to' parameter" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let target;
|
|
360
|
+
try {
|
|
361
|
+
target = new URL(to, "http://localhost");
|
|
362
|
+
} catch {
|
|
363
|
+
sendJson(nodeRes, 400, { kind: "error", message: "Invalid 'to' parameter" });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Navigation never targets framework internals; let the browser handle it.
|
|
368
|
+
if (target.pathname === "/_smaoog" || target.pathname.startsWith(RESERVED_PREFIX)) {
|
|
369
|
+
sendJson(nodeRes, 200, { kind: "reload" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const match = matchRoute(routes, target.pathname);
|
|
374
|
+
if (!match) {
|
|
375
|
+
// No route — the full-page request would 404, so the client swaps to the
|
|
376
|
+
// 404 page using the same payload shape.
|
|
377
|
+
await renderErrorPage(nodeRes, "notFound", { status: 404, mode: "nav" });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The handler runs as a GET for the target URL (path + query), so it sees
|
|
382
|
+
// the same params and query it would when serving the page directly.
|
|
383
|
+
await dispatchMatch(nodeReq, nodeRes, {
|
|
384
|
+
match,
|
|
385
|
+
method: "GET",
|
|
386
|
+
head: false,
|
|
387
|
+
mode: "nav",
|
|
388
|
+
reqUrl: to,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return async function handle(nodeReq, nodeRes) {
|
|
393
|
+
const method = (nodeReq.method ?? "GET").toUpperCase();
|
|
394
|
+
const head = method === "HEAD";
|
|
395
|
+
// An enhanced <Form> mutation carries the submit header; it serializes
|
|
396
|
+
// render/redirect results as JSON envelopes while leaving plain responses
|
|
397
|
+
// untouched. Everything else is an ordinary full-load (html) request.
|
|
398
|
+
// Computed up front so unmatched and error paths serialize consistently too.
|
|
399
|
+
const mode = nodeReq.headers[SUBMIT_HEADER] !== undefined ? "form" : "html";
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const path = pathnameOf(nodeReq.url);
|
|
403
|
+
|
|
404
|
+
// Reserved prefix: never matched against user content. The navigation
|
|
405
|
+
// endpoint lives here; production client assets are served here; anything
|
|
406
|
+
// else is a 404, but a distinguishable one.
|
|
407
|
+
if (path === "/_smaoog" || path.startsWith(RESERVED_PREFIX)) {
|
|
408
|
+
if (path === NAV_PATH) {
|
|
409
|
+
await handleNav(nodeReq, nodeRes);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (assetsRoot && path.startsWith(ASSET_PREFIX)) {
|
|
413
|
+
const assetPath = path.slice(ASSET_PREFIX.length - 1);
|
|
414
|
+
if (await sendFile(assetsRoot, assetPath, nodeReq, nodeRes)) return;
|
|
415
|
+
}
|
|
416
|
+
sendText(nodeRes, 404, "Not Found", { "X-Smaoog-Reserved": "1" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const match = matchRoute(routes, path);
|
|
421
|
+
if (!match) {
|
|
422
|
+
// Dev-only: serve linked source stylesheets, scoped to src/ so the path
|
|
423
|
+
// can't resolve to other .css files under the root. When a Vite
|
|
424
|
+
// transformer is wired (the `smaoog dev` path), the CSS goes through it
|
|
425
|
+
// so Tailwind/PostCSS and `@import` resolution run — matching the
|
|
426
|
+
// production stylesheet pipeline. `?direct` makes Vite return compiled
|
|
427
|
+
// CSS rather than the HMR JS module a bare import would yield. A compile
|
|
428
|
+
// error throws and surfaces on the dev error page; otherwise (no
|
|
429
|
+
// transformer, or nothing to transform) the raw file is streamed.
|
|
430
|
+
if (srcRoot && path.startsWith("/src/") && path.endsWith(".css")) {
|
|
431
|
+
const cssPath = path.slice("/src".length);
|
|
432
|
+
// Only transform a stylesheet that exists, and only for GET/HEAD (the
|
|
433
|
+
// guard sendFile enforces). A missing file or a write method falls
|
|
434
|
+
// through to a 404 as the raw path did, rather than letting Vite throw
|
|
435
|
+
// for a missing module and turning it into a 500. A transform error on
|
|
436
|
+
// a file that *does* exist still surfaces on the dev error page.
|
|
437
|
+
const method = (nodeReq.method ?? "GET").toUpperCase();
|
|
438
|
+
if (
|
|
439
|
+
transformStylesheet &&
|
|
440
|
+
(method === "GET" || method === "HEAD") &&
|
|
441
|
+
(await isFile(srcRoot, cssPath))
|
|
442
|
+
) {
|
|
443
|
+
const result = await transformStylesheet(path + "?direct");
|
|
444
|
+
if (result) {
|
|
445
|
+
nodeRes.statusCode = 200;
|
|
446
|
+
nodeRes.setHeader("content-type", "text/css; charset=utf-8");
|
|
447
|
+
nodeRes.setHeader("content-length", String(Buffer.byteLength(result.code)));
|
|
448
|
+
nodeRes.end(method === "HEAD" ? undefined : result.code);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (await sendFile(srcRoot, cssPath, nodeReq, nodeRes)) return;
|
|
453
|
+
}
|
|
454
|
+
// Dev: hand transformed browser modules and Vite's own endpoints
|
|
455
|
+
// (`/@…`, `/node_modules/…`, non-CSS `/src/…`) to Vite. This runs only
|
|
456
|
+
// after route matching, so a user route that happens to live at one of
|
|
457
|
+
// those URLs still wins — dev and prod resolve it the same way.
|
|
458
|
+
if (viteMiddlewares && isViteModuleRequest(path)) {
|
|
459
|
+
await new Promise((done) => {
|
|
460
|
+
viteMiddlewares(nodeReq, nodeRes, () => {
|
|
461
|
+
if (!nodeRes.headersSent) sendText(nodeRes, 404, "Not Found");
|
|
462
|
+
done();
|
|
463
|
+
});
|
|
464
|
+
nodeRes.on("close", done);
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (await sendFile(publicRoot, path, nodeReq, nodeRes)) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
await renderErrorPage(nodeRes, "notFound", { status: 404, head, mode });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
await dispatchMatch(nodeReq, nodeRes, {
|
|
476
|
+
match,
|
|
477
|
+
method,
|
|
478
|
+
head,
|
|
479
|
+
mode,
|
|
480
|
+
reqUrl: nodeReq.url,
|
|
481
|
+
});
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if (!nodeRes.headersSent) {
|
|
484
|
+
await renderErrorPage(nodeRes, "error", {
|
|
485
|
+
status: 500,
|
|
486
|
+
props: errorProps(err),
|
|
487
|
+
head,
|
|
488
|
+
mode,
|
|
489
|
+
});
|
|
490
|
+
} else if (!nodeRes.writableEnded) {
|
|
491
|
+
// A handler wrote through res.raw and then threw: headers are already
|
|
492
|
+
// out, so end the response rather than leaving the client hanging.
|
|
493
|
+
nodeRes.end();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Route source extensions, in no particular precedence (a single path may only
|
|
5
|
+
// have one route file; duplicates across extensions are a conflict).
|
|
6
|
+
export const ROUTE_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];
|
|
7
|
+
|
|
8
|
+
// The framework-reserved namespace under src/routes.
|
|
9
|
+
const RESERVED_SEGMENT = "_smaoog";
|
|
10
|
+
|
|
11
|
+
// Recursively collect every file under `dir` (absolute paths).
|
|
12
|
+
async function walk(dir) {
|
|
13
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
14
|
+
const files = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const full = join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
files.push(...(await walk(full)));
|
|
19
|
+
} else if (entry.isFile()) {
|
|
20
|
+
files.push(full);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Strip a known route extension from a filename; returns null if it isn't a
|
|
27
|
+
// route file.
|
|
28
|
+
function stripExtension(name) {
|
|
29
|
+
for (const ext of ROUTE_EXTENSIONS) {
|
|
30
|
+
if (name.endsWith(ext)) return name.slice(0, -ext.length);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Turn a raw path segment ("posts", "[id]") into a route segment, rejecting
|
|
36
|
+
// unsupported syntax (catch-all/optional are not in v0).
|
|
37
|
+
function toSegment(raw) {
|
|
38
|
+
if (raw.startsWith("[") || raw.endsWith("]")) {
|
|
39
|
+
const match = /^\[([A-Za-z0-9_]+)\]$/.exec(raw);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Unsupported route segment "${raw}": only single [param] segments are supported in v0`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return { type: "param", name: match[1] };
|
|
46
|
+
}
|
|
47
|
+
return { type: "static", value: raw };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build the pattern string ("/posts/:id") from route segments.
|
|
51
|
+
function toPattern(segments) {
|
|
52
|
+
if (segments.length === 0) return "/";
|
|
53
|
+
return (
|
|
54
|
+
"/" +
|
|
55
|
+
segments.map((s) => (s.type === "static" ? s.value : `:${s.name}`)).join("/")
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A structural key that ignores param names, so two dynamic routes at the same
|
|
60
|
+
// position ("[id]" vs "[slug]") collide.
|
|
61
|
+
function matchKey(segments) {
|
|
62
|
+
return segments.map((s) => (s.type === "static" ? s.value : ":")).join("/");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Scan `routesDir` and return a deterministic, conflict-checked list of routes.
|
|
66
|
+
export async function scanRoutes(routesDir) {
|
|
67
|
+
const files = await walk(routesDir);
|
|
68
|
+
const routes = [];
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const rel = relative(routesDir, file);
|
|
72
|
+
const routeId = `src/routes/${rel.split(sep).join("/")}`;
|
|
73
|
+
const parts = rel.split(sep);
|
|
74
|
+
|
|
75
|
+
const stripped = stripExtension(parts[parts.length - 1]);
|
|
76
|
+
if (stripped === null) continue; // not a route file
|
|
77
|
+
|
|
78
|
+
// Raw names: folders + filename without extension.
|
|
79
|
+
const rawSegments = [...parts.slice(0, -1), stripped];
|
|
80
|
+
|
|
81
|
+
// Reserved namespace check runs before the generic underscore rule so the
|
|
82
|
+
// user gets a clear error instead of a silent skip.
|
|
83
|
+
if (rawSegments[0] === RESERVED_SEGMENT) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`"${RESERVED_SEGMENT}" is reserved for the framework; user routes cannot live under src/routes/${RESERVED_SEGMENT}/`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Files/folders starting with "_" are private and ignored by the router.
|
|
90
|
+
if (rawSegments.some((s) => s.startsWith("_"))) continue;
|
|
91
|
+
|
|
92
|
+
// A trailing "index" denotes the folder's index route.
|
|
93
|
+
const routeNames =
|
|
94
|
+
rawSegments[rawSegments.length - 1] === "index"
|
|
95
|
+
? rawSegments.slice(0, -1)
|
|
96
|
+
: rawSegments;
|
|
97
|
+
|
|
98
|
+
const segments = routeNames.map(toSegment);
|
|
99
|
+
|
|
100
|
+
routes.push({
|
|
101
|
+
id: routeId,
|
|
102
|
+
file,
|
|
103
|
+
pattern: toPattern(segments),
|
|
104
|
+
segments,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Conflict detection: any two routes resolving to the same structural path.
|
|
109
|
+
const seen = new Map();
|
|
110
|
+
for (const route of routes) {
|
|
111
|
+
const key = matchKey(route.segments);
|
|
112
|
+
const other = seen.get(key);
|
|
113
|
+
if (other) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Route conflict: "${other.id}" and "${route.id}" resolve to the same path`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
seen.set(key, route);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Deterministic order regardless of filesystem read order.
|
|
122
|
+
routes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
123
|
+
return routes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// More-static-first comparator for routes of equal length.
|
|
127
|
+
function compareSpecificity(a, b) {
|
|
128
|
+
for (let i = 0; i < a.segments.length; i++) {
|
|
129
|
+
const ta = a.segments[i].type;
|
|
130
|
+
const tb = b.segments[i].type;
|
|
131
|
+
if (ta !== tb) return ta === "static" ? -1 : 1;
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Match a pathname against the scanned routes. Returns { route, params } for
|
|
137
|
+
// the most specific match (static beats dynamic), or null.
|
|
138
|
+
export function matchRoute(routes, pathname) {
|
|
139
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
140
|
+
|
|
141
|
+
const candidates = [];
|
|
142
|
+
for (const route of routes) {
|
|
143
|
+
if (route.segments.length !== parts.length) continue;
|
|
144
|
+
|
|
145
|
+
const params = {};
|
|
146
|
+
let matched = true;
|
|
147
|
+
for (let i = 0; i < parts.length; i++) {
|
|
148
|
+
const seg = route.segments[i];
|
|
149
|
+
if (seg.type === "static") {
|
|
150
|
+
if (seg.value !== parts[i]) {
|
|
151
|
+
matched = false;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
let value;
|
|
156
|
+
try {
|
|
157
|
+
value = decodeURIComponent(parts[i]);
|
|
158
|
+
} catch {
|
|
159
|
+
value = parts[i];
|
|
160
|
+
}
|
|
161
|
+
Object.defineProperty(params, seg.name, {
|
|
162
|
+
value,
|
|
163
|
+
enumerable: true,
|
|
164
|
+
configurable: true,
|
|
165
|
+
writable: true,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (matched) candidates.push({ route, params });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (candidates.length === 0) return null;
|
|
173
|
+
candidates.sort((a, b) => compareSpecificity(a.route, b.route));
|
|
174
|
+
return candidates[0];
|
|
175
|
+
}
|
package/src/serialize.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Ids for the inline JSON scripts the server emits and the client runtime reads.
|
|
2
|
+
// They carry the hydration props and the route identity/module for the page.
|
|
3
|
+
export const PROPS_SCRIPT_ID = "__SMAOOG_PROPS__";
|
|
4
|
+
export const ROUTE_SCRIPT_ID = "__SMAOOG_ROUTE__";
|
|
5
|
+
|
|
6
|
+
// Serialize a value for embedding inside `<script type="application/json">`.
|
|
7
|
+
// JSON is valid as element text except for the few sequences that could end the
|
|
8
|
+
// script element or break HTML/JS line parsing, so each is escaped to its JSON
|
|
9
|
+
// unicode form (still valid JSON the client parses back losslessly):
|
|
10
|
+
// < > & stop a literal "</script>" or entity-ish sequence,
|
|
11
|
+
// U+2028 / U+2029 are raw newlines in a JS string context.
|
|
12
|
+
export function serializeJsonForScript(value) {
|
|
13
|
+
return JSON.stringify(value ?? null).replace(
|
|
14
|
+
/[<>&\u2028\u2029]/g,
|
|
15
|
+
(ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"),
|
|
16
|
+
);
|
|
17
|
+
}
|