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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { HEAD_MARKER, metaToTags } from "../meta.js";
|
|
2
|
+
|
|
3
|
+
// The endpoint that returns navigation payloads (see router.js).
|
|
4
|
+
const NAV_ENDPOINT = "/_smaoog/nav";
|
|
5
|
+
|
|
6
|
+
// Installed by hydrate(): the route store to swap pages through, and the module
|
|
7
|
+
// loader used to import page modules. Until then (and during SSR) navigation
|
|
8
|
+
// falls back to a full page load.
|
|
9
|
+
let store = null;
|
|
10
|
+
let loadModule = null;
|
|
11
|
+
|
|
12
|
+
// Wire navigation to the hydrated app. Called once, from hydrate().
|
|
13
|
+
export function installNavigation({ store: routeStore, load }) {
|
|
14
|
+
store = routeStore;
|
|
15
|
+
loadModule = load;
|
|
16
|
+
if (typeof window !== "undefined") {
|
|
17
|
+
// Back/forward: re-render the destination without pushing a new entry. The
|
|
18
|
+
// browser has already moved the URL (hash included), so we pass it through.
|
|
19
|
+
window.addEventListener("popstate", () => {
|
|
20
|
+
const { pathname, search, hash } = window.location;
|
|
21
|
+
void navigate(pathname + search + hash, { history: "none" });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isModifiedEvent(event) {
|
|
27
|
+
return (
|
|
28
|
+
event.defaultPrevented ||
|
|
29
|
+
event.button !== 0 ||
|
|
30
|
+
event.metaKey ||
|
|
31
|
+
event.ctrlKey ||
|
|
32
|
+
event.shiftKey ||
|
|
33
|
+
event.altKey
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Same document (same path + query)? Used to leave in-page hash links alone.
|
|
38
|
+
function isSameDocument(url) {
|
|
39
|
+
return url.pathname === window.location.pathname && url.search === window.location.search;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Whether a click on an `<a href>` should be handled by client navigation rather
|
|
43
|
+
// than the browser. Used by <Link>. External origins and non-navigational hrefs
|
|
44
|
+
// (mailto:, other origins) are left to the browser, as are same-document hash
|
|
45
|
+
// links so native anchor scrolling keeps working.
|
|
46
|
+
export function shouldInterceptClick(event, href, target) {
|
|
47
|
+
if (isModifiedEvent(event)) return false;
|
|
48
|
+
if (target && target !== "_self") return false;
|
|
49
|
+
if (typeof href !== "string" || href === "") return false;
|
|
50
|
+
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(href, window.location.href);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (url.origin !== window.location.origin) return false;
|
|
58
|
+
if (url.hash && isSameDocument(url)) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Replace the framework-managed head tags with the ones for this route's meta.
|
|
63
|
+
// The marker (HEAD_MARKER) identifies the tags the framework owns — both the
|
|
64
|
+
// ones the server rendered and the ones added here — so the user's own <head>
|
|
65
|
+
// is never touched.
|
|
66
|
+
function applyHead(meta) {
|
|
67
|
+
if (typeof document === "undefined") return;
|
|
68
|
+
let tags;
|
|
69
|
+
try {
|
|
70
|
+
tags = metaToTags(meta ?? {});
|
|
71
|
+
} catch {
|
|
72
|
+
return; // bad meta shouldn't break navigation
|
|
73
|
+
}
|
|
74
|
+
for (const node of document.head.querySelectorAll(`[${HEAD_MARKER}]`)) {
|
|
75
|
+
node.remove();
|
|
76
|
+
}
|
|
77
|
+
for (const tag of tags) {
|
|
78
|
+
const el = document.createElement(tag.tag);
|
|
79
|
+
el.setAttribute(HEAD_MARKER, "");
|
|
80
|
+
if (tag.tag === "title") {
|
|
81
|
+
el.textContent = tag.text ?? "";
|
|
82
|
+
} else {
|
|
83
|
+
for (const [name, value] of Object.entries(tag.attrs)) el.setAttribute(name, String(value));
|
|
84
|
+
}
|
|
85
|
+
document.head.appendChild(el);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fullLoad(target) {
|
|
90
|
+
if (typeof window !== "undefined") window.location.assign(target);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// After a page swap, mirror the browser's anchor behavior: scroll to the
|
|
94
|
+
// hash target if there is a resolvable one, otherwise scroll to the top.
|
|
95
|
+
function scrollToHashOrTop(hash) {
|
|
96
|
+
if (hash && hash.length > 1) {
|
|
97
|
+
const raw = hash.slice(1);
|
|
98
|
+
// A malformed escape (e.g. "#%E0%A4%A") would throw; fall back to the raw id.
|
|
99
|
+
let id;
|
|
100
|
+
try {
|
|
101
|
+
id = decodeURIComponent(raw);
|
|
102
|
+
} catch {
|
|
103
|
+
id = raw;
|
|
104
|
+
}
|
|
105
|
+
const el = document.getElementById(id);
|
|
106
|
+
if (el) {
|
|
107
|
+
el.scrollIntoView();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
window.scrollTo(0, 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Navigate to `href` without a full reload: fetch the navigation payload, swap
|
|
115
|
+
// the page in the store, update history and head, and scroll to top. Any
|
|
116
|
+
// uncertainty (cross-origin, a non-page response, a transport or import failure)
|
|
117
|
+
// falls back to a full browser navigation so the user always gets somewhere.
|
|
118
|
+
//
|
|
119
|
+
// `history`: "push" (default) adds an entry, "replace" replaces the current one,
|
|
120
|
+
// "none" updates in place (used by popstate, where the browser already moved).
|
|
121
|
+
export async function navigate(href, { history: historyMode = "push", load = loadModule } = {}) {
|
|
122
|
+
if (!store || typeof window === "undefined") {
|
|
123
|
+
fullLoad(href);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const url = new URL(href, window.location.href);
|
|
128
|
+
if (url.origin !== window.location.origin) {
|
|
129
|
+
fullLoad(href);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// The server only routes on path + query; the hash is client-only, but it is
|
|
133
|
+
// preserved in the history entry and used to scroll after the swap.
|
|
134
|
+
const path = url.pathname + url.search;
|
|
135
|
+
const historyTarget = path + url.hash;
|
|
136
|
+
|
|
137
|
+
let payload;
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${NAV_ENDPOINT}?to=${encodeURIComponent(path)}`, {
|
|
140
|
+
headers: { accept: "application/json" },
|
|
141
|
+
});
|
|
142
|
+
if (!(res.headers.get("content-type") || "").includes("application/json")) {
|
|
143
|
+
fullLoad(historyTarget);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
payload = await res.json();
|
|
147
|
+
} catch {
|
|
148
|
+
fullLoad(historyTarget);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (payload.kind === "redirect") {
|
|
153
|
+
// The redirecting URL was never added to history (only the final page is),
|
|
154
|
+
// so there is nothing to skip: the destination inherits this navigation's
|
|
155
|
+
// history mode. A popstate ("none") replaces so the URL bar reflects where
|
|
156
|
+
// we actually landed.
|
|
157
|
+
const mode = historyMode === "none" ? "replace" : historyMode;
|
|
158
|
+
await navigate(payload.location, { history: mode, load });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (payload.kind !== "render") {
|
|
163
|
+
fullLoad(historyTarget);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await applyRenderPayload(payload, { url: historyTarget, history: historyMode, load });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Swap the page to a render payload that the caller already has in hand: import
|
|
171
|
+
// the page module, update history to `url`, replace the page and managed head in
|
|
172
|
+
// the store, and scroll. Shared by client navigation (which fetched the payload
|
|
173
|
+
// from /_smaoog/nav) and enhanced <Form> mutations (whose own response was a
|
|
174
|
+
// render envelope). On a failure to import the page it falls back to a full
|
|
175
|
+
// load. `scroll` defaults on; an in-place form swap (validation errors) passes
|
|
176
|
+
// false to keep the user where they are.
|
|
177
|
+
export async function applyRenderPayload(
|
|
178
|
+
payload,
|
|
179
|
+
{ url, history: historyMode = "push", scroll = true, load = loadModule } = {},
|
|
180
|
+
) {
|
|
181
|
+
if (!store || typeof window === "undefined") {
|
|
182
|
+
fullLoad(url);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let mod;
|
|
187
|
+
try {
|
|
188
|
+
mod = await load(payload.module);
|
|
189
|
+
} catch {
|
|
190
|
+
fullLoad(url);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const Page = mod?.default;
|
|
194
|
+
if (typeof Page !== "function") {
|
|
195
|
+
fullLoad(url);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (historyMode === "push") {
|
|
200
|
+
window.history.pushState(null, "", url);
|
|
201
|
+
} else if (historyMode === "replace") {
|
|
202
|
+
window.history.replaceState(null, "", url);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
store.set({ Page, props: payload.props ?? {}, routeId: payload.routeId });
|
|
206
|
+
applyHead(payload.meta);
|
|
207
|
+
if (scroll) scrollToHashOrTop(new URL(url, window.location.href).hash);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { hydrateRoot } from "react-dom/client";
|
|
3
|
+
import { PROPS_SCRIPT_ID, ROUTE_SCRIPT_ID } from "../serialize.js";
|
|
4
|
+
import { createRouteStore, Root } from "./store.js";
|
|
5
|
+
import { installNavigation } from "./navigation.js";
|
|
6
|
+
|
|
7
|
+
// The element the server renders the page into (Document's `<main id="root">`).
|
|
8
|
+
const ROOT_ID = "root";
|
|
9
|
+
|
|
10
|
+
function readJsonScript(id) {
|
|
11
|
+
const el = document.getElementById(id);
|
|
12
|
+
const text = el?.textContent;
|
|
13
|
+
return text ? JSON.parse(text) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hydrate the server-rendered page in place: read the route bootstrap, import the
|
|
17
|
+
// page module, and attach React to the existing DOM with the SSR props. The page
|
|
18
|
+
// is rendered through a route store so client navigation can later swap it
|
|
19
|
+
// without a reload; the store is wired into the navigation runtime here.
|
|
20
|
+
//
|
|
21
|
+
// `load` imports a page module by URL — injectable so the runtime can be driven
|
|
22
|
+
// in tests (and by client navigation) without a bundler. It defaults to a native
|
|
23
|
+
// dynamic import, which Vite (dev) / the hashed asset (prod) resolves.
|
|
24
|
+
export async function hydrate({ load = (url) => import(/* @vite-ignore */ url) } = {}) {
|
|
25
|
+
const route = readJsonScript(ROUTE_SCRIPT_ID);
|
|
26
|
+
// No bootstrap -> nothing to hydrate (e.g. a static/error page). Quietly do
|
|
27
|
+
// nothing rather than treat a deliberately-static page as an error.
|
|
28
|
+
if (!route?.module) return null;
|
|
29
|
+
|
|
30
|
+
const container = document.getElementById(ROOT_ID);
|
|
31
|
+
if (!container) {
|
|
32
|
+
console.error(`[smaoog] cannot hydrate: no #${ROOT_ID} element found`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mod = await load(route.module);
|
|
37
|
+
const Page = mod?.default;
|
|
38
|
+
if (typeof Page !== "function") {
|
|
39
|
+
console.error(`[smaoog] cannot hydrate "${route.id}": its module has no default export`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const props = readJsonScript(PROPS_SCRIPT_ID) ?? {};
|
|
44
|
+
const store = createRouteStore({ Page, props, routeId: route.id });
|
|
45
|
+
installNavigation({ store, load });
|
|
46
|
+
return hydrateRoot(container, React.createElement(Root, { store }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// A tiny external store holding the currently displayed route — its page
|
|
4
|
+
// component, props, and route id. Hydration seeds it from the server-rendered
|
|
5
|
+
// page; client navigation replaces its value to swap the page in place. It is
|
|
6
|
+
// deliberately framework-agnostic state (no React inside) so navigation can live
|
|
7
|
+
// outside the React tree.
|
|
8
|
+
export function createRouteStore(initial) {
|
|
9
|
+
let state = initial;
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
return {
|
|
12
|
+
get: () => state,
|
|
13
|
+
set(next) {
|
|
14
|
+
state = next;
|
|
15
|
+
for (const listener of listeners) listener();
|
|
16
|
+
},
|
|
17
|
+
subscribe(listener) {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The root component React renders into #root. It subscribes to the route store
|
|
25
|
+
// and renders whatever page is current, so a store update (from navigation)
|
|
26
|
+
// re-renders the whole page without a reload. It adds no DOM of its own, so the
|
|
27
|
+
// hydrated markup matches the server's exactly.
|
|
28
|
+
export function Root({ store }) {
|
|
29
|
+
const state = React.useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
30
|
+
return React.createElement(state.Page, state.props);
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Candidate locations for the user-owned document, in precedence order.
|
|
5
|
+
export const DOCUMENT_FILES = [
|
|
6
|
+
"src/document.tsx",
|
|
7
|
+
"src/document.jsx",
|
|
8
|
+
"src/document.ts",
|
|
9
|
+
"src/document.js",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// Candidate locations for the user-owned error pages, in precedence order.
|
|
13
|
+
export const ERROR_PAGE_FILES = {
|
|
14
|
+
notFound: [
|
|
15
|
+
"src/routes/_404.tsx",
|
|
16
|
+
"src/routes/_404.jsx",
|
|
17
|
+
"src/routes/_404.ts",
|
|
18
|
+
"src/routes/_404.js",
|
|
19
|
+
],
|
|
20
|
+
error: [
|
|
21
|
+
"src/routes/_500.tsx",
|
|
22
|
+
"src/routes/_500.jsx",
|
|
23
|
+
"src/routes/_500.ts",
|
|
24
|
+
"src/routes/_500.js",
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Conventional browser entry and global stylesheet locations. The helpers in
|
|
29
|
+
// Document can opt into them without requiring a user config file.
|
|
30
|
+
export const CLIENT_ENTRY_FILES = [
|
|
31
|
+
"src/client-entry.tsx",
|
|
32
|
+
"src/client-entry.jsx",
|
|
33
|
+
"src/client-entry.ts",
|
|
34
|
+
"src/client-entry.js",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const STYLESHEET_FILES = ["src/app.css"];
|
|
38
|
+
|
|
39
|
+
// The first of `files` (root-relative paths) that exists, returned as a
|
|
40
|
+
// root-relative path, or null. Callers turn it into a Vite id or an absolute
|
|
41
|
+
// path as needed.
|
|
42
|
+
export function findFirst(root, files) {
|
|
43
|
+
for (const rel of files) {
|
|
44
|
+
if (existsSync(join(root, rel))) return rel;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { DOCUMENT_FILES, ERROR_PAGE_FILES, findFirst } from "./conventions.js";
|
|
4
|
+
import { createRequestHandler } from "./router.js";
|
|
5
|
+
import { scanRoutes } from "./routes.js";
|
|
6
|
+
import { createServer, listen } from "./server.js";
|
|
7
|
+
import { createViteServer, loadModule } from "./vite.js";
|
|
8
|
+
|
|
9
|
+
// Turn an absolute route file path into a Vite root-relative module id
|
|
10
|
+
// ("/src/routes/posts/[id].tsx"), with forward slashes on every platform.
|
|
11
|
+
function toViteId(root, file) {
|
|
12
|
+
return "/" + relative(root, file).split(sep).join("/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A module loader backed by Vite's SSR pipeline: route modules, the document,
|
|
16
|
+
// and error pages are all transformed and loaded on demand in dev.
|
|
17
|
+
export function createDevLoader({ vite, root }) {
|
|
18
|
+
const documentRel = findFirst(root, DOCUMENT_FILES);
|
|
19
|
+
const errorRel = {
|
|
20
|
+
notFound: findFirst(root, ERROR_PAGE_FILES.notFound),
|
|
21
|
+
error: findFirst(root, ERROR_PAGE_FILES.error),
|
|
22
|
+
};
|
|
23
|
+
const id = (rel) => (rel ? loadModule(vite, `/${rel}`) : Promise.resolve(null));
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
route: (route) => loadModule(vite, toViteId(root, route.file)),
|
|
27
|
+
document: () => id(documentRel),
|
|
28
|
+
errorPage: (kind) => id(errorRel[kind]),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Wire the dev loader and source/public roots into the framework request
|
|
33
|
+
// handler. Tests use this directly; startDev uses it below.
|
|
34
|
+
export function createDevRequestHandler({ vite, root, routes, options = {} }) {
|
|
35
|
+
return createRequestHandler({
|
|
36
|
+
routes,
|
|
37
|
+
loader: createDevLoader({ vite, root }),
|
|
38
|
+
publicRoot: join(root, "public"),
|
|
39
|
+
srcRoot: join(root, "src"),
|
|
40
|
+
// Vite serves transformed browser modules (the client entry, page modules)
|
|
41
|
+
// and its own dev endpoints; the framework keeps routing and serves CSS
|
|
42
|
+
// through Vite's transform (so Tailwind/PostCSS/@import run in dev too).
|
|
43
|
+
viteMiddlewares: vite.middlewares,
|
|
44
|
+
transformStylesheet: (id) => vite.transformRequest(id),
|
|
45
|
+
dev: true,
|
|
46
|
+
options,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Orchestrate `smaoog dev`: scan the file routes, boot the internal Vite module
|
|
51
|
+
// boundary, and start the node:http server with the framework request handler.
|
|
52
|
+
// Vite is created internally — the user only ever runs `smaoog dev`.
|
|
53
|
+
export async function startDev({ root = process.cwd(), port = 3000 } = {}) {
|
|
54
|
+
// A brand-new project may not have a routes dir yet; treat that as no routes.
|
|
55
|
+
const routesDir = join(root, "src", "routes");
|
|
56
|
+
const routes = existsSync(routesDir) ? await scanRoutes(routesDir) : [];
|
|
57
|
+
|
|
58
|
+
// The server is created before Vite so it can be shared with Vite's HMR
|
|
59
|
+
// WebSocket; the request handler (which needs Vite's middlewares) is attached
|
|
60
|
+
// afterwards, and we only listen once everything is wired. HMR upgrades and
|
|
61
|
+
// ordinary requests are separate events on the server, so the order is safe.
|
|
62
|
+
const server = createServer();
|
|
63
|
+
const vite = await createViteServer({ root, server });
|
|
64
|
+
const requestHandler = createDevRequestHandler({ vite, root, routes });
|
|
65
|
+
server.on("request", requestHandler);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await listen(server, port);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// Don't leak the Vite server if the HTTP server can't bind.
|
|
71
|
+
await vite.close();
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
server,
|
|
77
|
+
vite,
|
|
78
|
+
routes,
|
|
79
|
+
async close() {
|
|
80
|
+
await new Promise((resolve) => server.close(resolve));
|
|
81
|
+
await vite.close();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/dispatch.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { HttpError } from "./errors.js";
|
|
2
|
+
import { createRequest } from "./request.js";
|
|
3
|
+
import { createResponse, isTerminal, sendResult } from "./response.js";
|
|
4
|
+
|
|
5
|
+
// Run a single route handler against a Node request/response: build the
|
|
6
|
+
// wrappers, invoke the handler, and apply whatever it returns. This is the
|
|
7
|
+
// seam route dispatch reuses. `options`:
|
|
8
|
+
// - bodyLimit, params -> request wrapper
|
|
9
|
+
// - canRender -> response wrapper (whether res.render() is allowed)
|
|
10
|
+
// - renderInfo -> response wrapper (route id + page component)
|
|
11
|
+
// - head -> send the result without a body (HEAD from GET)
|
|
12
|
+
// - notFoundHandler -> router-owned renderer for res.notFound()
|
|
13
|
+
// - errorHandler -> router-owned renderer for ordinary thrown errors
|
|
14
|
+
export async function runHandler(handler, nodeReq, nodeRes, options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
head = false,
|
|
17
|
+
mode = "html",
|
|
18
|
+
canRender,
|
|
19
|
+
renderInfo,
|
|
20
|
+
notFoundHandler,
|
|
21
|
+
errorHandler,
|
|
22
|
+
...requestOptions
|
|
23
|
+
} = options;
|
|
24
|
+
const req = createRequest(nodeReq, requestOptions);
|
|
25
|
+
const res = createResponse(nodeRes, { canRender, renderInfo });
|
|
26
|
+
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await handler(req, res);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// Framework HTTP errors (malformed JSON -> 400, body too large -> 413, ...)
|
|
32
|
+
// become that status. Other errors propagate to error pages (Phase 11).
|
|
33
|
+
if (err instanceof HttpError) {
|
|
34
|
+
if (!nodeRes.headersSent) {
|
|
35
|
+
nodeRes.statusCode = err.status;
|
|
36
|
+
nodeRes.setHeader("content-type", "text/plain; charset=utf-8");
|
|
37
|
+
nodeRes.end(err.message);
|
|
38
|
+
} else if (!nodeRes.writableEnded) {
|
|
39
|
+
// Headers already flushed (handler wrote through res.raw): end the
|
|
40
|
+
// response so the client isn't left hanging.
|
|
41
|
+
nodeRes.end();
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (errorHandler && !nodeRes.headersSent) {
|
|
46
|
+
await errorHandler(err, { head });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isTerminal(result)) {
|
|
53
|
+
if (result.kind === "notFound" && notFoundHandler) {
|
|
54
|
+
await notFoundHandler(result, { head });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
sendResult(nodeRes, result, { head, mode });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Handler responded directly through the res.raw / req.raw escape hatch.
|
|
62
|
+
if (nodeRes.headersSent || nodeRes.writableEnded) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handler returned nothing terminal and wrote nothing: a missing return.
|
|
67
|
+
if (errorHandler) {
|
|
68
|
+
await errorHandler(new Error("Handler did not return a response"), { head });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
nodeRes.statusCode = 500;
|
|
73
|
+
nodeRes.setHeader("content-type", "text/plain; charset=utf-8");
|
|
74
|
+
nodeRes.end("Handler did not return a response");
|
|
75
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A framework-level HTTP error that maps directly to a status code (e.g. a
|
|
2
|
+
// malformed JSON body -> 400, an over-limit body -> 413). The dispatcher
|
|
3
|
+
// turns these into that status; ordinary thrown errors are handled by the
|
|
4
|
+
// router's error-page path.
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message ?? `HTTP ${status}`);
|
|
8
|
+
this.name = "HttpError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/form.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { applyRenderPayload, navigate } from "./client/navigation.js";
|
|
3
|
+
import { RESPONSE_HEADER, SUBMIT_HEADER } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
// Whether this form carries a file upload, which v0 does not support over the
|
|
6
|
+
// enhanced fetch path (FormData files can't be url-encoded). Such forms should
|
|
7
|
+
// use a plain <form>.
|
|
8
|
+
function isMultipart(form) {
|
|
9
|
+
if ((form.getAttribute("enctype") || "").toLowerCase() === "multipart/form-data") return true;
|
|
10
|
+
return Array.from(form.elements).some((el) => el.type === "file");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function readJson(response) {
|
|
14
|
+
if (!(response.headers.get("content-type") || "").includes("application/json")) return null;
|
|
15
|
+
try {
|
|
16
|
+
return await response.json();
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A client-enhanced form for route mutations and GET search/navigation. It
|
|
23
|
+
// renders a real <form> (so it works before hydration), then once hydrated takes
|
|
24
|
+
// over submission:
|
|
25
|
+
//
|
|
26
|
+
// method="get" -> build a query URL and client-navigate, like <Link>.
|
|
27
|
+
// mutations -> submit with fetch; the response decides what happens:
|
|
28
|
+
// redirect envelope -> navigate
|
|
29
|
+
// render envelope -> swap the page in place (validation)
|
|
30
|
+
// 2xx JSON -> onSuccess(data, response)
|
|
31
|
+
// 4xx/5xx JSON -> onError(data, response)
|
|
32
|
+
//
|
|
33
|
+
// `action` defaults to the current route. There is no hidden `_method` field;
|
|
34
|
+
// the real method goes out on the fetch.
|
|
35
|
+
export function Form({ action, method = "get", onSuccess, onError, children, ...rest }) {
|
|
36
|
+
const normalizedMethod = String(method).toLowerCase();
|
|
37
|
+
const isGet = normalizedMethod === "get";
|
|
38
|
+
const [submitting, setSubmitting] = React.useState(false);
|
|
39
|
+
// Synchronous in-flight guard: state updates are async, so a ref is what
|
|
40
|
+
// actually blocks a second submit landing before the first finishes.
|
|
41
|
+
const inFlight = React.useRef(false);
|
|
42
|
+
|
|
43
|
+
async function handleSubmit(event) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
if (inFlight.current) return;
|
|
46
|
+
|
|
47
|
+
const form = event.currentTarget;
|
|
48
|
+
const here = window.location.pathname + window.location.search;
|
|
49
|
+
|
|
50
|
+
// File uploads aren't supported over the enhanced path (GET or mutation):
|
|
51
|
+
// FormData files can't be url-encoded. Bow out clearly; use a plain <form>.
|
|
52
|
+
if (isMultipart(form)) {
|
|
53
|
+
console.error(
|
|
54
|
+
"[smaoog] <Form> does not support file uploads in v0; use a plain <form> for multipart submissions.",
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isGet) {
|
|
60
|
+
// Search/navigation form: fold the fields into the query and navigate.
|
|
61
|
+
const url = new URL(action ?? window.location.pathname, window.location.href);
|
|
62
|
+
url.search = new URLSearchParams(new FormData(form)).toString();
|
|
63
|
+
await navigate(url.pathname + url.search);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const submitUrl = action ?? here;
|
|
68
|
+
inFlight.current = true;
|
|
69
|
+
setSubmitting(true);
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(submitUrl, {
|
|
72
|
+
method: normalizedMethod.toUpperCase(),
|
|
73
|
+
headers: {
|
|
74
|
+
[SUBMIT_HEADER]: "1",
|
|
75
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
76
|
+
accept: "application/json",
|
|
77
|
+
},
|
|
78
|
+
body: new URLSearchParams(new FormData(form)).toString(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const envelope = response.headers.get(RESPONSE_HEADER);
|
|
82
|
+
if (envelope === "redirect") {
|
|
83
|
+
const { location } = await response.json();
|
|
84
|
+
await navigate(location);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (envelope === "render") {
|
|
88
|
+
// Validation/mutation render: swap the current page in place (no scroll,
|
|
89
|
+
// no new history entry) so the URL stays put with the new props.
|
|
90
|
+
await applyRenderPayload(await response.json(), {
|
|
91
|
+
url: submitUrl,
|
|
92
|
+
history: "replace",
|
|
93
|
+
scroll: false,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = await readJson(response);
|
|
99
|
+
if (response.ok) onSuccess?.(data, response);
|
|
100
|
+
else onError?.(data, response);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Transport failure (offline, aborted, etc.) — no response to pass.
|
|
103
|
+
onError?.(null, undefined);
|
|
104
|
+
} finally {
|
|
105
|
+
inFlight.current = false;
|
|
106
|
+
setSubmitting(false);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The native method attribute only understands get/post; mutations go out as
|
|
111
|
+
// POST without JS (the real verb rides the fetch after hydration). This is the
|
|
112
|
+
// documented v0 limitation, not a hidden field.
|
|
113
|
+
const nativeMethod = isGet ? "get" : "post";
|
|
114
|
+
const content = typeof children === "function" ? children({ submitting }) : children;
|
|
115
|
+
|
|
116
|
+
return React.createElement(
|
|
117
|
+
"form",
|
|
118
|
+
{ ...rest, action, method: nativeMethod, onSubmit: handleSubmit },
|
|
119
|
+
content,
|
|
120
|
+
);
|
|
121
|
+
}
|