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 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,6 @@
1
+ export interface HydrateOptions {
2
+ load?: (url: string) => Promise<{ default?: unknown }>;
3
+ }
4
+
5
+ export function hydrate(options?: HydrateOptions): Promise<unknown>;
6
+
@@ -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 };