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/response.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { RESPONSE_HEADER } from "./protocol.js";
|
|
2
|
+
import { renderIntentToHtml, renderIntentToNav } from "./render.js";
|
|
3
|
+
|
|
4
|
+
// Marks a value as a terminal framework response so the dispatcher can tell a
|
|
5
|
+
// real response apart from a handler that forgot to return one.
|
|
6
|
+
const TERMINAL = Symbol("smaoog.terminal");
|
|
7
|
+
|
|
8
|
+
function terminal(result) {
|
|
9
|
+
Object.defineProperty(result, TERMINAL, { value: true });
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isTerminal(value) {
|
|
14
|
+
return Boolean(value) && value[TERMINAL] === true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertSerializableProps(value, what = "res.render()") {
|
|
18
|
+
const prefix = `${what} props must be JSON-serializable`;
|
|
19
|
+
try {
|
|
20
|
+
JSON.stringify(value, (key, item) => {
|
|
21
|
+
if (
|
|
22
|
+
item === undefined ||
|
|
23
|
+
typeof item === "function" ||
|
|
24
|
+
typeof item === "symbol" ||
|
|
25
|
+
typeof item === "bigint"
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`${prefix}; "${key}" is ${typeof item}`);
|
|
28
|
+
}
|
|
29
|
+
return item;
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err.message?.startsWith(prefix)) throw err;
|
|
33
|
+
throw new Error(prefix);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Shared shape for a render intent. Both producers — res.render() and the
|
|
38
|
+
// router's error-page path — build intents through here so the structure can't
|
|
39
|
+
// drift; hydration fields (Phase 15) get added in one place.
|
|
40
|
+
export function createRenderIntent({
|
|
41
|
+
status,
|
|
42
|
+
headers,
|
|
43
|
+
props,
|
|
44
|
+
routeId = null,
|
|
45
|
+
Page,
|
|
46
|
+
Document,
|
|
47
|
+
routeModule,
|
|
48
|
+
client = null,
|
|
49
|
+
}) {
|
|
50
|
+
return { kind: "render", status, headers, props, routeId, Page, Document, routeModule, client };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Wrap a Node ServerResponse in smaoog's response surface. The helpers are
|
|
54
|
+
// "terminal": they describe the response and return it; the dispatcher is what
|
|
55
|
+
// writes it to the socket. They never touch the Node response directly (use
|
|
56
|
+
// res.raw for that) — that is what makes missing-return detection possible.
|
|
57
|
+
//
|
|
58
|
+
// `canRender` is whether the route has a default export (a page component) to
|
|
59
|
+
// render. The route dispatcher sets it; standalone callers default to true.
|
|
60
|
+
export function createResponse(nodeRes, { canRender = true, renderInfo = {} } = {}) {
|
|
61
|
+
let status = 200;
|
|
62
|
+
const headers = {};
|
|
63
|
+
|
|
64
|
+
const res = {
|
|
65
|
+
// Escape hatch: the raw Node ServerResponse.
|
|
66
|
+
raw: nodeRes,
|
|
67
|
+
|
|
68
|
+
// Chainable status setter.
|
|
69
|
+
status(code) {
|
|
70
|
+
status = code;
|
|
71
|
+
return res;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Chainable header setter (merges).
|
|
75
|
+
headers(object) {
|
|
76
|
+
Object.assign(headers, object);
|
|
77
|
+
return res;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
text(body) {
|
|
81
|
+
return terminal({
|
|
82
|
+
kind: "send",
|
|
83
|
+
status,
|
|
84
|
+
headers: { "content-type": "text/plain; charset=utf-8", ...headers },
|
|
85
|
+
body: String(body),
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
json(data) {
|
|
90
|
+
return terminal({
|
|
91
|
+
kind: "send",
|
|
92
|
+
status,
|
|
93
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
|
94
|
+
body: JSON.stringify(data),
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Redirects default to 303 (See Other). The location argument is explicit
|
|
99
|
+
// intent, so it wins over any chained `headers({ location })`.
|
|
100
|
+
redirect(location, code = 303) {
|
|
101
|
+
return terminal({
|
|
102
|
+
kind: "send",
|
|
103
|
+
status: code,
|
|
104
|
+
headers: { ...headers, location },
|
|
105
|
+
body: "",
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Placeholder RenderIntent until React SSR exists (Phase 8). Rendering is
|
|
110
|
+
// only meaningful when the route has a page (default export); an
|
|
111
|
+
// endpoint-only route has nothing to render.
|
|
112
|
+
render(props = {}) {
|
|
113
|
+
if (arguments.length > 1) {
|
|
114
|
+
throw new Error("res.render() takes only props, not a component or element");
|
|
115
|
+
}
|
|
116
|
+
if (!canRender) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"res.render() requires the route to have a default export (the page component); this route has none.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
assertSerializableProps(props);
|
|
122
|
+
return terminal(
|
|
123
|
+
createRenderIntent({
|
|
124
|
+
status,
|
|
125
|
+
headers: { ...headers },
|
|
126
|
+
props,
|
|
127
|
+
routeId: renderInfo.routeId ?? null,
|
|
128
|
+
Page: renderInfo.Page,
|
|
129
|
+
Document: renderInfo.Document,
|
|
130
|
+
routeModule: renderInfo.routeModule,
|
|
131
|
+
client: renderInfo.client,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// Render the app's 404 page (or the framework default) through the router.
|
|
137
|
+
// notFound props are serialized for hydration just like render() props, so
|
|
138
|
+
// they get the same JSON-serializable guarantee.
|
|
139
|
+
notFound(props = {}) {
|
|
140
|
+
assertSerializableProps(props, "res.notFound()");
|
|
141
|
+
return terminal({
|
|
142
|
+
kind: "notFound",
|
|
143
|
+
status: 404,
|
|
144
|
+
headers: { ...headers },
|
|
145
|
+
props,
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return res;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Headers the framework owns on the wire and a route must never set, so a
|
|
154
|
+
// handler can't forge or clobber the envelope marker / content type.
|
|
155
|
+
const RESERVED_JSON_HEADERS = new Set(["content-type", RESPONSE_HEADER]);
|
|
156
|
+
|
|
157
|
+
// Write a JSON body, applying route-set headers but never the framework-reserved
|
|
158
|
+
// ones — the body shape and the envelope marker are the framework's, not the
|
|
159
|
+
// handler's. (The caller sets RESPONSE_HEADER directly when an envelope needs it,
|
|
160
|
+
// and that survives because it isn't part of `headers`.)
|
|
161
|
+
export function sendJson(nodeRes, status, payload, headers = {}) {
|
|
162
|
+
nodeRes.statusCode = status;
|
|
163
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
164
|
+
if (RESERVED_JSON_HEADERS.has(key.toLowerCase())) continue;
|
|
165
|
+
nodeRes.setHeader(key, value);
|
|
166
|
+
}
|
|
167
|
+
nodeRes.setHeader("content-type", "application/json; charset=utf-8");
|
|
168
|
+
nodeRes.end(JSON.stringify(payload));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Write a plain `send` result (text/json/redirect) exactly as the handler
|
|
172
|
+
// described it. This is the full-load form and also what an enhanced <Form>
|
|
173
|
+
// receives for a handler's own res.json()/res.text() (so onSuccess/onError see
|
|
174
|
+
// the real status and body).
|
|
175
|
+
function sendPlain(nodeRes, result, head) {
|
|
176
|
+
nodeRes.statusCode = result.status;
|
|
177
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
178
|
+
// RESPONSE_HEADER is framework-owned; a plain passthrough must not carry one
|
|
179
|
+
// (real for a form's onSuccess/onError) or the client would misread it as an
|
|
180
|
+
// envelope. content-type is the handler's here, so it is preserved.
|
|
181
|
+
if (key.toLowerCase() === RESPONSE_HEADER) continue;
|
|
182
|
+
nodeRes.setHeader(key, value);
|
|
183
|
+
}
|
|
184
|
+
nodeRes.end(head ? undefined : result.body);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Serialize a terminal result for an "enhanced" client request — `nav` (a
|
|
188
|
+
// /_smaoog/nav GET) or `form` (a <Form> mutation). render() and redirect()
|
|
189
|
+
// become JSON envelopes the client runtime swaps/navigates on; the RESPONSE_HEADER
|
|
190
|
+
// names the envelope kind so the client can tell it apart from a handler's own
|
|
191
|
+
// JSON. Everything is sent 200 so the browser's fetch doesn't follow a redirect
|
|
192
|
+
// as a real one — the directive lives in the body. Route-set headers ride along
|
|
193
|
+
// so an enhanced request matches a full load (a handler's Set-Cookie, cache
|
|
194
|
+
// directives, etc. take effect either way).
|
|
195
|
+
//
|
|
196
|
+
// The two modes differ only on a plain `send`: a `form` passes it through for
|
|
197
|
+
// the callbacks, while `nav` (which only ever targets pages) asks for a full
|
|
198
|
+
// reload.
|
|
199
|
+
function sendEnhancedResult(nodeRes, result, mode) {
|
|
200
|
+
if (result.kind === "render") {
|
|
201
|
+
nodeRes.setHeader(RESPONSE_HEADER, "render");
|
|
202
|
+
sendJson(nodeRes, result.status, renderIntentToNav(result), result.headers);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (result.kind === "send") {
|
|
206
|
+
const location = result.headers?.location;
|
|
207
|
+
if (location && result.status >= 300 && result.status < 400) {
|
|
208
|
+
nodeRes.setHeader(RESPONSE_HEADER, "redirect");
|
|
209
|
+
// The destination lives in the payload body; drop the Location header
|
|
210
|
+
// (any casing) so it isn't a stray Location on a 200, but keep the rest
|
|
211
|
+
// (e.g. Set-Cookie).
|
|
212
|
+
const rest = Object.fromEntries(
|
|
213
|
+
Object.entries(result.headers).filter(([key]) => key.toLowerCase() !== "location"),
|
|
214
|
+
);
|
|
215
|
+
sendJson(nodeRes, 200, { kind: "redirect", location, status: result.status }, rest);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (mode === "form") {
|
|
219
|
+
// A handler's own response: hand it back verbatim for onSuccess/onError.
|
|
220
|
+
sendPlain(nodeRes, result, false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// nav mode: the target wasn't a page, so the client does a full load.
|
|
224
|
+
sendJson(nodeRes, 200, { kind: "reload" }, result.headers);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`Cannot send a "${result.kind}" ${mode} result`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Write a terminal result to the Node response. `head` writes the status and
|
|
231
|
+
// headers but omits the body, for HEAD requests served from a GET handler.
|
|
232
|
+
// `mode` selects the wire format: "html" (full load), "nav" (client navigation
|
|
233
|
+
// payload), or "form" (enhanced form mutation response).
|
|
234
|
+
export function sendResult(nodeRes, result, { head = false, mode = "html" } = {}) {
|
|
235
|
+
if (mode === "nav" || mode === "form") {
|
|
236
|
+
sendEnhancedResult(nodeRes, result, mode);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (result.kind === "send") {
|
|
241
|
+
sendPlain(nodeRes, result, head);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (result.kind === "render") {
|
|
246
|
+
nodeRes.statusCode = result.status;
|
|
247
|
+
nodeRes.setHeader("content-type", "text/html; charset=utf-8");
|
|
248
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
249
|
+
nodeRes.setHeader(key, value);
|
|
250
|
+
}
|
|
251
|
+
nodeRes.end(head ? undefined : renderIntentToHtml(result));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new Error(`Cannot send a "${result.kind}" result yet`);
|
|
256
|
+
}
|