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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aggelos Gesoulis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# smaoog
|
|
2
|
+
|
|
3
|
+
A lightweight, SSR React framework for standalone Node, with file-based routing.
|
|
4
|
+
|
|
5
|
+
> **Status: alpha (`0.0.x`).** The API is still settling and may change between releases.
|
|
6
|
+
|
|
7
|
+
smaoog renders React on the server, hydrates it in the browser and routes by the files in your `src/routes` directory. It runs as a plain long-lived Node server. That means no edge runtime support is planned.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
The fastest way to start is the scaffolder:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm create smaoog my-app
|
|
15
|
+
cd my-app
|
|
16
|
+
npm install
|
|
17
|
+
npm run dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That gives you a TypeScript app with Tailwind wired up. Pass `--js` for the JavaScript app.
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
### File routes
|
|
25
|
+
|
|
26
|
+
Files under `src/routes` become routes:
|
|
27
|
+
|
|
28
|
+
```txt
|
|
29
|
+
src/routes/index.tsx -> /
|
|
30
|
+
src/routes/about.tsx -> /about
|
|
31
|
+
src/routes/posts/index.tsx -> /posts
|
|
32
|
+
src/routes/posts/[id].tsx -> /posts/:id
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A module's **default export** is the page component. You can export named HTTP method handlers (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) for endpoints and mutations. A default export with no `GET` is automatically the route's `GET` handler.
|
|
36
|
+
|
|
37
|
+
```jsx
|
|
38
|
+
// src/routes/posts/[id].jsx
|
|
39
|
+
export async function GET(req, res) {
|
|
40
|
+
const post = await loadPost(req.params.id);
|
|
41
|
+
return res.render({ post });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function Post({ post }) {
|
|
45
|
+
return <article>{post.title}</article>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Handlers
|
|
50
|
+
|
|
51
|
+
Handlers receive request/response wrappers:
|
|
52
|
+
|
|
53
|
+
```txt
|
|
54
|
+
req.method req.path req.params req.query req.headers
|
|
55
|
+
await req.body() await req.rawBody() req.raw
|
|
56
|
+
|
|
57
|
+
res.status(code) res.headers({...}) res.text() res.json()
|
|
58
|
+
res.redirect(path) res.render(props?) res.notFound(props?)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Navigation and forms
|
|
62
|
+
|
|
63
|
+
- `<Link href>` does client-side navigation without a full reload.
|
|
64
|
+
- `<Form>` enhances mutations and GET search forms. A handler can answer a submission with `res.redirect()`, JSON (`onSuccess`/`onError`), or `res.render()` to swap the page in place — the standard server-validation flow.
|
|
65
|
+
|
|
66
|
+
```jsx
|
|
67
|
+
import { Link, Form } from "smaoog";
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Metadata
|
|
71
|
+
|
|
72
|
+
Export `meta` (an object or a function of `{ props }`) to manage the document head:
|
|
73
|
+
|
|
74
|
+
```jsx
|
|
75
|
+
export const meta = { title: "Posts", description: "All posts" };
|
|
76
|
+
or
|
|
77
|
+
export function meta({ props }) {
|
|
78
|
+
return { title: props.title };
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Server-only modules
|
|
83
|
+
|
|
84
|
+
Any module named `*.server.{js,jsx,ts,tsx}` is server-only. Route handlers and the server build may import it. If it ever reaches the client bundle the build fails.Use it for database clients and secrets.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
// src/db.server.js
|
|
88
|
+
import { MongoClient } from "mongodb";
|
|
89
|
+
export const db = new MongoClient(process.env.MONGO_URL).db("app");
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Commands
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
smaoog dev # development server
|
|
96
|
+
smaoog build # production build into .smaoog/
|
|
97
|
+
smaoog start # serve the production build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smaoog",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A lightweight SSR React framework for standalone Node with file-based routing.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Aggelos Gesoulis",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"react",
|
|
11
|
+
"ssr",
|
|
12
|
+
"framework",
|
|
13
|
+
"node"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/anges244/smaoog#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/anges244/smaoog.git",
|
|
19
|
+
"directory": "packages/smaoog"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/anges244/smaoog/issues"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"smaoog": "./src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./src/index.d.ts",
|
|
33
|
+
"default": "./src/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./client": {
|
|
36
|
+
"types": "./src/client/index.d.ts",
|
|
37
|
+
"default": "./src/client/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"!src/**/*.test.js"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
46
|
+
"react": "^19.2.0",
|
|
47
|
+
"react-dom": "^19.2.0",
|
|
48
|
+
"vite": "^6.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/assets.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const ASSET_PREFIX = "/_smaoog/assets/";
|
|
2
|
+
|
|
3
|
+
function trimLeadingSlash(value) {
|
|
4
|
+
return value.replace(/^\/+/, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Strip a leading slash so a source path can be compared by its in-app form
|
|
8
|
+
// (e.g. "/src/app.css" and "src/app.css" both normalize to "src/app.css").
|
|
9
|
+
export function normalizeAppPath(path) {
|
|
10
|
+
return trimLeadingSlash(String(path));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function appPath(path) {
|
|
14
|
+
const value = String(path);
|
|
15
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The public URL for a built asset. Build output file names are relative to the
|
|
19
|
+
// client out dir ("assets/app-HASH.css"); they are served under the reserved
|
|
20
|
+
// asset prefix, so the leading "assets/" is folded into ASSET_PREFIX.
|
|
21
|
+
export function assetHref(file) {
|
|
22
|
+
return ASSET_PREFIX + trimLeadingSlash(file).replace(/^assets\//, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The Phase 14 client manifest is built straight from the client build's Rollup
|
|
26
|
+
// output — entry chunks carry their input `name`, their emitted `fileName`, and
|
|
27
|
+
// the CSS they import; standalone stylesheet inputs come out as CSS assets named
|
|
28
|
+
// after their input. Reading the output object avoids parsing Vite's manifest
|
|
29
|
+
// JSON and guessing at its keys.
|
|
30
|
+
//
|
|
31
|
+
// entry: { id, file, href, css, imports } | null
|
|
32
|
+
// routes: { [routeId]: { file, href, css, imports } } page routes only
|
|
33
|
+
// stylesheets: { [source]: { file, href } }
|
|
34
|
+
//
|
|
35
|
+
// Later phases (hydration, navigation) consume this shape; they must not
|
|
36
|
+
// redefine it.
|
|
37
|
+
export function createClientManifest({
|
|
38
|
+
output = [],
|
|
39
|
+
routeEntries = [],
|
|
40
|
+
clientEntry = null,
|
|
41
|
+
stylesheets = [],
|
|
42
|
+
}) {
|
|
43
|
+
const entryChunks = new Map();
|
|
44
|
+
const cssAssets = new Map();
|
|
45
|
+
for (const item of output) {
|
|
46
|
+
if (item.type === "chunk" && item.isEntry) {
|
|
47
|
+
entryChunks.set(item.name, item);
|
|
48
|
+
} else if (item.type === "asset" && item.fileName?.endsWith(".css")) {
|
|
49
|
+
// A stylesheet input emits a CSS asset whose name is "<input>.css".
|
|
50
|
+
const base = (item.name ?? "").replace(/\.css$/, "");
|
|
51
|
+
if (base) cssAssets.set(base, item.fileName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const chunkAsset = (name) => {
|
|
56
|
+
const chunk = entryChunks.get(name);
|
|
57
|
+
if (!chunk) return null;
|
|
58
|
+
return {
|
|
59
|
+
file: chunk.fileName,
|
|
60
|
+
href: assetHref(chunk.fileName),
|
|
61
|
+
css: [...(chunk.viteMetadata?.importedCss ?? [])].map(assetHref),
|
|
62
|
+
imports: (chunk.imports ?? []).map(assetHref),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const routes = {};
|
|
67
|
+
for (const { id, name } of routeEntries) {
|
|
68
|
+
const asset = chunkAsset(name);
|
|
69
|
+
if (asset) routes[id] = asset;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stylesheetAssets = {};
|
|
73
|
+
for (const { source, name } of stylesheets) {
|
|
74
|
+
const file = cssAssets.get(name);
|
|
75
|
+
if (file) stylesheetAssets[source] = { file, href: assetHref(file) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entryAsset = clientEntry ? chunkAsset(clientEntry.name) : null;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
assetPrefix: ASSET_PREFIX,
|
|
83
|
+
entry: entryAsset ? { id: clientEntry.id, ...entryAsset } : null,
|
|
84
|
+
routes,
|
|
85
|
+
stylesheets: stylesheetAssets,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The render-time view of the client build: it resolves the dev/prod URL for a
|
|
90
|
+
// stylesheet, the browser entry, and a page's hydration module. The Document
|
|
91
|
+
// helpers (Stylesheet/ClientEntry) read it through the render context.
|
|
92
|
+
//
|
|
93
|
+
// dev: modules and styles are served from source by Vite, so URLs are the
|
|
94
|
+
// in-app source path ("/src/app.css", "/src/routes/index.tsx").
|
|
95
|
+
// prod: URLs come from the Phase 14 manifest's hashed assets.
|
|
96
|
+
export function createClientContext({ dev = false, manifest = null } = {}) {
|
|
97
|
+
return {
|
|
98
|
+
dev,
|
|
99
|
+
stylesheetHref(source) {
|
|
100
|
+
if (dev) return appPath(source);
|
|
101
|
+
return manifest?.stylesheets?.[normalizeAppPath(source)]?.href ?? appPath(source);
|
|
102
|
+
},
|
|
103
|
+
entryHref(source) {
|
|
104
|
+
if (dev) return appPath(source);
|
|
105
|
+
// Resolve by the declared source, like stylesheets: a <ClientEntry src>
|
|
106
|
+
// that doesn't match the built entry resolves to nothing in prod, matching
|
|
107
|
+
// dev (where the wrong source 404s) instead of silently using the entry.
|
|
108
|
+
const entry = manifest?.entry;
|
|
109
|
+
return entry && normalizeAppPath(source) === entry.id ? entry.href : null;
|
|
110
|
+
},
|
|
111
|
+
pageModuleHref(routeId) {
|
|
112
|
+
if (!routeId) return null;
|
|
113
|
+
if (dev) return appPath(routeId);
|
|
114
|
+
return manifest?.routes?.[routeId]?.href ?? null;
|
|
115
|
+
},
|
|
116
|
+
page: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
import { build as viteBuild } from "vite";
|
|
6
|
+
import { createClientManifest } from "./assets.js";
|
|
7
|
+
import {
|
|
8
|
+
CLIENT_ENTRY_FILES,
|
|
9
|
+
DOCUMENT_FILES,
|
|
10
|
+
ERROR_PAGE_FILES,
|
|
11
|
+
STYLESHEET_FILES,
|
|
12
|
+
findFirst,
|
|
13
|
+
} from "./conventions.js";
|
|
14
|
+
import { scanRoutes } from "./routes.js";
|
|
15
|
+
import { serverOnlyBuildGuard } from "./server-only.js";
|
|
16
|
+
import { loadTailwindPlugin } from "./tailwind.js";
|
|
17
|
+
|
|
18
|
+
// Where the production build is written. `smaoog start` reads only from here.
|
|
19
|
+
export const OUTPUT_DIR = ".smaoog";
|
|
20
|
+
|
|
21
|
+
// Virtual module id for a route's browser entry. It re-exports only the page
|
|
22
|
+
// component (see routeClientPlugin), so the route's HTTP handlers — and anything
|
|
23
|
+
// they import — tree-shake out of the client bundle.
|
|
24
|
+
const VIRTUAL_ROUTE_PREFIX = "\0smaoog-route-client:";
|
|
25
|
+
|
|
26
|
+
// Fresh plugin instances per build — Vite plugins are stateful for one build, so
|
|
27
|
+
// the SSR and client passes must not share them.
|
|
28
|
+
async function appPlugins(root) {
|
|
29
|
+
return [react(), ...(await loadTailwindPlugin(root))];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Exposes a route's default export (the page) as a standalone client entry,
|
|
33
|
+
// dropping its server-side handlers via tree-shaking.
|
|
34
|
+
function routeClientPlugin() {
|
|
35
|
+
return {
|
|
36
|
+
name: "smaoog:route-client",
|
|
37
|
+
resolveId(id) {
|
|
38
|
+
if (id.startsWith(VIRTUAL_ROUTE_PREFIX)) return id;
|
|
39
|
+
},
|
|
40
|
+
load(id) {
|
|
41
|
+
if (id.startsWith(VIRTUAL_ROUTE_PREFIX)) {
|
|
42
|
+
const file = id.slice(VIRTUAL_ROUTE_PREFIX.length);
|
|
43
|
+
return `export { default } from ${JSON.stringify(file)};`;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Flatten viteBuild's result into a single Rollup output array.
|
|
50
|
+
function collectOutput(result) {
|
|
51
|
+
const outputs = Array.isArray(result) ? result : [result];
|
|
52
|
+
return outputs.flatMap((out) => out.output ?? []);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build the production output under `.smaoog`:
|
|
56
|
+
// .smaoog/server/*.js built route/document/error-page modules (ESM)
|
|
57
|
+
// .smaoog/client/assets/ hashed browser assets owned by smaoog
|
|
58
|
+
// .smaoog/manifest.json route table + server module map + client manifest
|
|
59
|
+
//
|
|
60
|
+
// Two Vite passes share one toolchain (no throwaway compiler path):
|
|
61
|
+
// 1. SSR pass — route/document/error-page modules the Node server renders.
|
|
62
|
+
// 2. client pass — per-page browser entries, the client entry, and stylesheets,
|
|
63
|
+
// emitted under the reserved asset prefix with the manifest hydration/nav
|
|
64
|
+
// will consume.
|
|
65
|
+
export async function buildApp({ root = process.cwd(), logLevel = "warn" } = {}) {
|
|
66
|
+
const routesDir = join(root, "src", "routes");
|
|
67
|
+
const routes = existsSync(routesDir) ? await scanRoutes(routesDir) : [];
|
|
68
|
+
|
|
69
|
+
const outDir = join(root, OUTPUT_DIR);
|
|
70
|
+
const serverDir = join(outDir, "server");
|
|
71
|
+
const clientDir = join(outDir, "client");
|
|
72
|
+
|
|
73
|
+
// Start clean so a stale module/asset from a previous build can never be
|
|
74
|
+
// served or referenced by the new manifest.
|
|
75
|
+
await rm(outDir, { recursive: true, force: true });
|
|
76
|
+
|
|
77
|
+
// Map a unique, filesystem-safe entry name to each source module. Names avoid
|
|
78
|
+
// route bracket syntax and stay parallel between the SSR and client passes.
|
|
79
|
+
const serverInput = {};
|
|
80
|
+
const manifestRoutes = routes.map((route, i) => {
|
|
81
|
+
serverInput[`route${i}`] = route.file;
|
|
82
|
+
return {
|
|
83
|
+
id: route.id,
|
|
84
|
+
pattern: route.pattern,
|
|
85
|
+
segments: route.segments,
|
|
86
|
+
module: `route${i}.js`,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const documentRel = findFirst(root, DOCUMENT_FILES);
|
|
91
|
+
if (documentRel) serverInput.document = join(root, documentRel);
|
|
92
|
+
const notFoundRel = findFirst(root, ERROR_PAGE_FILES.notFound);
|
|
93
|
+
if (notFoundRel) serverInput._404 = join(root, notFoundRel);
|
|
94
|
+
const errorRel = findFirst(root, ERROR_PAGE_FILES.error);
|
|
95
|
+
if (errorRel) serverInput._500 = join(root, errorRel);
|
|
96
|
+
|
|
97
|
+
// --- SSR pass ---------------------------------------------------------------
|
|
98
|
+
let ssrOutput = [];
|
|
99
|
+
if (Object.keys(serverInput).length > 0) {
|
|
100
|
+
const result = await viteBuild({
|
|
101
|
+
root,
|
|
102
|
+
configFile: false,
|
|
103
|
+
logLevel,
|
|
104
|
+
plugins: await appPlugins(root),
|
|
105
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
106
|
+
// Keep node_modules deps out of the SSR bundle; they resolve at runtime.
|
|
107
|
+
ssr: { external: ["smaoog", "react", "react-dom"] },
|
|
108
|
+
build: {
|
|
109
|
+
ssr: true,
|
|
110
|
+
outDir: serverDir,
|
|
111
|
+
emptyOutDir: true,
|
|
112
|
+
// SSR output is JS modules only; public assets are served from public/
|
|
113
|
+
// at runtime, not copied into the server bundle dir.
|
|
114
|
+
copyPublicDir: false,
|
|
115
|
+
rollupOptions: {
|
|
116
|
+
input: serverInput,
|
|
117
|
+
output: {
|
|
118
|
+
format: "es",
|
|
119
|
+
entryFileNames: "[name].js",
|
|
120
|
+
chunkFileNames: "chunks/[name]-[hash].js",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
ssrOutput = collectOutput(result);
|
|
126
|
+
} else {
|
|
127
|
+
await mkdir(serverDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A route gets a browser entry only if it has a page (default export). The
|
|
131
|
+
// SSR chunk's export list tells us this without executing the module — so a
|
|
132
|
+
// route's server-only imports never run at build time.
|
|
133
|
+
const ssrExports = new Map();
|
|
134
|
+
for (const item of ssrOutput) {
|
|
135
|
+
if (item.type === "chunk") ssrExports.set(item.name, item.exports ?? []);
|
|
136
|
+
}
|
|
137
|
+
const isPageRoute = (i) => (ssrExports.get(`route${i}`) ?? []).includes("default");
|
|
138
|
+
|
|
139
|
+
// --- client pass ------------------------------------------------------------
|
|
140
|
+
const clientInput = {};
|
|
141
|
+
const routeEntries = [];
|
|
142
|
+
routes.forEach((route, i) => {
|
|
143
|
+
if (!isPageRoute(i)) return;
|
|
144
|
+
const name = `route${i}`;
|
|
145
|
+
clientInput[name] = VIRTUAL_ROUTE_PREFIX + route.file;
|
|
146
|
+
routeEntries.push({ id: route.id, name });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const clientEntryRel = findFirst(root, CLIENT_ENTRY_FILES);
|
|
150
|
+
const clientEntry = clientEntryRel
|
|
151
|
+
? { id: clientEntryRel, name: "client-entry" }
|
|
152
|
+
: null;
|
|
153
|
+
if (clientEntry) clientInput[clientEntry.name] = join(root, clientEntryRel);
|
|
154
|
+
|
|
155
|
+
const stylesheets = STYLESHEET_FILES.filter((rel) => existsSync(join(root, rel))).map(
|
|
156
|
+
(rel, i) => {
|
|
157
|
+
const name = `style${i}`;
|
|
158
|
+
clientInput[name] = join(root, rel);
|
|
159
|
+
return { source: rel, name };
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
let client;
|
|
164
|
+
if (Object.keys(clientInput).length > 0) {
|
|
165
|
+
const result = await viteBuild({
|
|
166
|
+
root,
|
|
167
|
+
configFile: false,
|
|
168
|
+
logLevel,
|
|
169
|
+
// Assets are served from the reserved prefix, so their public URLs (and
|
|
170
|
+
// any CSS url() rewrites) resolve there.
|
|
171
|
+
base: "/_smaoog/",
|
|
172
|
+
// The server-only guard runs first so it can reject (or tree-shake) a
|
|
173
|
+
// `.server.*` import before other plugins resolve it.
|
|
174
|
+
plugins: [serverOnlyBuildGuard({ routesDir }), routeClientPlugin(), ...(await appPlugins(root))],
|
|
175
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
176
|
+
build: {
|
|
177
|
+
outDir: clientDir,
|
|
178
|
+
emptyOutDir: true,
|
|
179
|
+
copyPublicDir: false,
|
|
180
|
+
rollupOptions: {
|
|
181
|
+
input: clientInput,
|
|
182
|
+
// Keep each entry's exports (the re-exported page component) so the
|
|
183
|
+
// page survives tree-shaking; the default (`false`) would drop it.
|
|
184
|
+
preserveEntrySignatures: "allow-extension",
|
|
185
|
+
output: {
|
|
186
|
+
entryFileNames: "assets/[name]-[hash].js",
|
|
187
|
+
chunkFileNames: "assets/[name]-[hash].js",
|
|
188
|
+
assetFileNames: "assets/[name]-[hash][extname]",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
client = createClientManifest({
|
|
194
|
+
output: collectOutput(result),
|
|
195
|
+
routeEntries,
|
|
196
|
+
clientEntry,
|
|
197
|
+
stylesheets,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
await mkdir(clientDir, { recursive: true });
|
|
201
|
+
client = createClientManifest({});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const manifest = {
|
|
205
|
+
routes: manifestRoutes,
|
|
206
|
+
document: documentRel ? "document.js" : null,
|
|
207
|
+
errorPages: {
|
|
208
|
+
notFound: notFoundRel ? "_404.js" : null,
|
|
209
|
+
error: errorRel ? "_500.js" : null,
|
|
210
|
+
},
|
|
211
|
+
client,
|
|
212
|
+
};
|
|
213
|
+
await writeFile(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
214
|
+
|
|
215
|
+
return { outDir, serverDir, clientDir, manifest };
|
|
216
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildApp } from "./build.js";
|
|
3
|
+
import { startDev } from "./dev.js";
|
|
4
|
+
import { startProd } from "./start.js";
|
|
5
|
+
|
|
6
|
+
// Run `smaoog dev`: start the dev server (Vite + node:http) and report the URL.
|
|
7
|
+
async function dev() {
|
|
8
|
+
const port = Number(process.env.PORT) || 3000;
|
|
9
|
+
const { server } = await startDev({ root: process.cwd(), port });
|
|
10
|
+
const { port: actualPort } = server.address();
|
|
11
|
+
console.log(`smaoog dev listening on http://localhost:${actualPort}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Run `smaoog build`: produce the SSR-only production output under .smaoog.
|
|
15
|
+
async function build() {
|
|
16
|
+
const { outDir } = await buildApp({ root: process.cwd() });
|
|
17
|
+
console.log(`smaoog build complete: ${outDir}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Run `smaoog start`: serve the built SSR app from .smaoog.
|
|
21
|
+
async function start() {
|
|
22
|
+
const port = Number(process.env.PORT) || 3000;
|
|
23
|
+
const { server } = await startProd({ root: process.cwd(), port });
|
|
24
|
+
const { port: actualPort } = server.address();
|
|
25
|
+
console.log(`smaoog start listening on http://localhost:${actualPort}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const command = process.argv[2];
|
|
30
|
+
|
|
31
|
+
switch (command) {
|
|
32
|
+
case "dev":
|
|
33
|
+
await dev();
|
|
34
|
+
break;
|
|
35
|
+
case "build":
|
|
36
|
+
await build();
|
|
37
|
+
break;
|
|
38
|
+
case "start":
|
|
39
|
+
await start();
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
if (command) {
|
|
43
|
+
console.error(`Unknown command: ${command}`);
|
|
44
|
+
} else {
|
|
45
|
+
console.error(
|
|
46
|
+
"Usage: smaoog <command>\n\nCommands:\n dev Start the dev server\n build Build the production SSR output\n start Serve the built production app",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((err) => {
|
|
54
|
+
// Surface a clean message (e.g. "run `smaoog build` first") instead of a
|
|
55
|
+
// Node stack trace for expected failures.
|
|
56
|
+
console.error(err?.message ?? err);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { hydrate } from "./runtime.js";
|
|
2
|
+
|
|
3
|
+
// `import "smaoog/client"` (from the app's client entry) boots hydration in the
|
|
4
|
+
// browser. Guarded so importing the entry without a DOM — SSR, tests, or a
|
|
5
|
+
// build that never reaches a browser — is a safe no-op. The catch keeps a failed
|
|
6
|
+
// dynamic import or a corrupted bootstrap (e.g. a stale deploy) from surfacing as
|
|
7
|
+
// an unhandled rejection.
|
|
8
|
+
if (typeof document !== "undefined") {
|
|
9
|
+
hydrate().catch((err) => console.error("[smaoog] hydration failed", err));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { hydrate };
|