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,122 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
// The server-only file convention: any module whose name ends in `.server.<ext>`
|
|
4
|
+
// is forbidden from the browser bundle. Route handlers and the SSR build may
|
|
5
|
+
// import these freely; the client may not.
|
|
6
|
+
//
|
|
7
|
+
// Matches a resolved file path or an import specifier with an extension, and
|
|
8
|
+
// tolerates a trailing query (Vite appends ?v=, ?import, etc.).
|
|
9
|
+
export const SERVER_MODULE_RE = /\.server\.[cm]?[jt]sx?(\?|$)/;
|
|
10
|
+
|
|
11
|
+
// Removes whole `import ... from "….server.*"` statements (and bare
|
|
12
|
+
// `import "….server.*"`) from source. The clause character class excludes quotes
|
|
13
|
+
// so a match can't span across a previous import's specifier. Used in dev, where
|
|
14
|
+
// there is no bundler to tree-shake a route's handler-only server imports out of
|
|
15
|
+
// the browser module.
|
|
16
|
+
const SERVER_IMPORT_RE =
|
|
17
|
+
/import\s+(?:[\w*${},\s]+\s+from\s+)?["'][^"']*\.server(?:\.[cm]?[jt]sx?)?["']\s*;?/g;
|
|
18
|
+
|
|
19
|
+
export function stripServerImports(code) {
|
|
20
|
+
return code.replace(SERVER_IMPORT_RE, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Browser route modules are hydrated through their default export only (the page
|
|
24
|
+
// component); a route's `meta` and HTTP handlers are read on the server. React
|
|
25
|
+
// Fast Refresh, however, only keeps a module hot when *every* export is a
|
|
26
|
+
// component — a stray `export const meta`/`export function GET` makes it bail to a
|
|
27
|
+
// full page reload. So in the browser transform we demote every non-default
|
|
28
|
+
// export to a plain local: the page keeps Fast Refresh, and the now-unused
|
|
29
|
+
// handlers/meta are harmless (and tree-shake away in the production build).
|
|
30
|
+
//
|
|
31
|
+
// Operates on raw TS/TSX (this runs before the JSX/TS transform), so it is a
|
|
32
|
+
// keyword rewrite, not an AST pass: drop the `export` keyword from a non-default
|
|
33
|
+
// declaration, and drop `export { … }` / `export * …` statements whole. Uses
|
|
34
|
+
// [ \t] rather than \s so line breaks survive and source positions stay stable.
|
|
35
|
+
const EXPORT_DECL_RE =
|
|
36
|
+
/\bexport[ \t]+(?!default\b)(?=(?:async[ \t]+)?(?:function|class|const|let|var)\b)/g;
|
|
37
|
+
const EXPORT_LIST_RE =
|
|
38
|
+
/\bexport[ \t]*(?:\{[^}]*\}|\*(?:[ \t]+as[ \t]+[\w$]+)?)[ \t]*(?:from[ \t]*["'][^"']*["'])?[ \t]*;?/g;
|
|
39
|
+
|
|
40
|
+
export function stripNonDefaultExports(code) {
|
|
41
|
+
return code.replace(EXPORT_DECL_RE, "").replace(EXPORT_LIST_RE, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Whether `file` lives under the routes directory — i.e. it is a route module,
|
|
45
|
+
// whose server imports belong to handlers (server-only) and are expected to be
|
|
46
|
+
// dropped from the client rather than rejected. A query suffix is ignored.
|
|
47
|
+
function isRouteModule(file, routesDir) {
|
|
48
|
+
if (!file) return false;
|
|
49
|
+
const path = file.split("?")[0];
|
|
50
|
+
return path.startsWith(routesDir.endsWith(sep) ? routesDir : routesDir + sep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function leakError(target, importer) {
|
|
54
|
+
const who = importer ? ` (imported by "${importer.split("?")[0]}")` : "";
|
|
55
|
+
return new Error(
|
|
56
|
+
`[smaoog] server-only module "${target.split("?")[0]}" must not reach the client bundle${who}. ` +
|
|
57
|
+
`Move browser-safe code out of .server.* files; import them only from route handlers.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build-pass guard for the client bundle. Route-handler `.server.*` imports are
|
|
62
|
+
// marked side-effect-free so an unused one tree-shakes away; a `.server.*`
|
|
63
|
+
// imported by any non-route browser module (client entry, a component) is a hard
|
|
64
|
+
// error; and if one still survives into a client chunk (a page actually used it),
|
|
65
|
+
// generateBundle rejects the build.
|
|
66
|
+
export function serverOnlyBuildGuard({ routesDir }) {
|
|
67
|
+
return {
|
|
68
|
+
name: "smaoog:server-only-build",
|
|
69
|
+
// Resolve before Vite's own resolver so the side-effect-free tag actually
|
|
70
|
+
// takes hold (a later resolver would fix the module's default side effects).
|
|
71
|
+
enforce: "pre",
|
|
72
|
+
async resolveId(source, importer, options) {
|
|
73
|
+
if (options?.ssr || !source.includes(".server")) return null;
|
|
74
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true, ...options });
|
|
75
|
+
if (!resolved || !SERVER_MODULE_RE.test(resolved.id)) return null;
|
|
76
|
+
if (isRouteModule(importer, routesDir)) {
|
|
77
|
+
// Handler-only import: let it tree-shake out (generateBundle catches it
|
|
78
|
+
// if a page actually pulled it into the client).
|
|
79
|
+
return { ...resolved, moduleSideEffects: false };
|
|
80
|
+
}
|
|
81
|
+
throw leakError(resolved.id, importer);
|
|
82
|
+
},
|
|
83
|
+
generateBundle(_options, bundle) {
|
|
84
|
+
for (const chunk of Object.values(bundle)) {
|
|
85
|
+
if (chunk.type !== "chunk") continue;
|
|
86
|
+
for (const moduleId of Object.keys(chunk.modules ?? {})) {
|
|
87
|
+
if (SERVER_MODULE_RE.test(moduleId)) throw leakError(moduleId, "the client bundle");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Dev guard + Fast Refresh shim for route modules. There is no bundler in dev,
|
|
95
|
+
// so a route module's browser transform strips its handler-only `.server.*`
|
|
96
|
+
// imports (the handlers never run in the browser) and demotes its non-default
|
|
97
|
+
// exports to locals so React Fast Refresh keeps the page hot. Any other
|
|
98
|
+
// browser-context `.server.*` import — from the client entry or a component — is
|
|
99
|
+
// a hard error.
|
|
100
|
+
export function serverOnlyDevPlugin({ routesDir }) {
|
|
101
|
+
return {
|
|
102
|
+
name: "smaoog:server-only-dev",
|
|
103
|
+
enforce: "pre",
|
|
104
|
+
transform(code, id, options) {
|
|
105
|
+
// Browser transform of a route module only. SSR keeps the handlers, meta,
|
|
106
|
+
// and server imports it actually runs.
|
|
107
|
+
if (options?.ssr || !isRouteModule(id, routesDir)) return null;
|
|
108
|
+
let out = code;
|
|
109
|
+
// Drop handler-only `.server.*` imports (no bundler to tree-shake them).
|
|
110
|
+
if (out.includes(".server")) out = stripServerImports(out);
|
|
111
|
+
// Demote non-default exports so React Fast Refresh keeps the page hot.
|
|
112
|
+
out = stripNonDefaultExports(out);
|
|
113
|
+
return out === code ? null : { code: out, map: null };
|
|
114
|
+
},
|
|
115
|
+
async resolveId(source, importer, options) {
|
|
116
|
+
if (options?.ssr || !source.includes(".server")) return null;
|
|
117
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true, ...options });
|
|
118
|
+
if (!resolved || !SERVER_MODULE_RE.test(resolved.id)) return null;
|
|
119
|
+
throw leakError(resolved.id, importer);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
// Reserved framework prefix. User routes can never be served from here;
|
|
4
|
+
// it is owned by smaoog internals (client assets, nav payloads, manifests).
|
|
5
|
+
export const RESERVED_PREFIX = "/_smaoog/";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PORT = 3000;
|
|
8
|
+
|
|
9
|
+
// Build the node:http server around a request handler, without binding it to a
|
|
10
|
+
// port. Tests use this so they can listen on an ephemeral port. The handler is
|
|
11
|
+
// the framework's request handler (see router.js); the server itself stays a
|
|
12
|
+
// thin transport with no routing knowledge.
|
|
13
|
+
export function createServer(requestHandler) {
|
|
14
|
+
return createHttpServer(requestHandler);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Bind an already-created server to a port. Resolves once it is listening, and
|
|
18
|
+
// rejects if binding fails (e.g. the port is already in use) so callers can
|
|
19
|
+
// recover instead of hanging. Split out from startServer so the dev path can
|
|
20
|
+
// create the server first (to share it with Vite's HMR WebSocket) and listen
|
|
21
|
+
// only after the request handler is attached.
|
|
22
|
+
export function listen(server, port = DEFAULT_PORT) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
function onError(err) {
|
|
25
|
+
server.removeListener("listening", onListening);
|
|
26
|
+
reject(err);
|
|
27
|
+
}
|
|
28
|
+
function onListening() {
|
|
29
|
+
server.removeListener("error", onError);
|
|
30
|
+
resolve(server);
|
|
31
|
+
}
|
|
32
|
+
server.once("error", onError);
|
|
33
|
+
server.once("listening", onListening);
|
|
34
|
+
server.listen(port);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create and start the server in one step. Resolves once it is listening, and
|
|
39
|
+
// rejects if binding fails.
|
|
40
|
+
export function startServer({ port = DEFAULT_PORT, requestHandler } = {}) {
|
|
41
|
+
return listen(createServer(requestHandler), port);
|
|
42
|
+
}
|
package/src/start.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { OUTPUT_DIR } from "./build.js";
|
|
6
|
+
import { createRequestHandler } from "./router.js";
|
|
7
|
+
import { startServer } from "./server.js";
|
|
8
|
+
|
|
9
|
+
// A module loader backed by the built output. Modules are imported from
|
|
10
|
+
// `.smaoog/server` via the manifest, so the running server never touches `src`.
|
|
11
|
+
function createProdLoader({ serverDir, manifest }) {
|
|
12
|
+
const importBuilt = (file) => import(pathToFileURL(join(serverDir, file)).href);
|
|
13
|
+
return {
|
|
14
|
+
route: (route) => importBuilt(route.module),
|
|
15
|
+
document: () => (manifest.document ? importBuilt(manifest.document) : Promise.resolve(null)),
|
|
16
|
+
errorPage: (kind) => {
|
|
17
|
+
const file = manifest.errorPages[kind];
|
|
18
|
+
return file ? importBuilt(file) : Promise.resolve(null);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Load the build manifest, or throw a clear error if the app hasn't been built.
|
|
24
|
+
async function loadManifest(root) {
|
|
25
|
+
const outDir = join(root, OUTPUT_DIR);
|
|
26
|
+
const manifestPath = join(outDir, "manifest.json");
|
|
27
|
+
if (!existsSync(manifestPath)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`No build found at ${OUTPUT_DIR}/. Run \`smaoog build\` before \`smaoog start\`.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
33
|
+
return { manifest, serverDir: join(outDir, "server") };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build the production request handler from the manifest, without binding a
|
|
37
|
+
// port. Useful for tests that drive an ephemeral port.
|
|
38
|
+
export async function createProdRequestHandler({ root = process.cwd() } = {}) {
|
|
39
|
+
const { manifest, serverDir } = await loadManifest(root);
|
|
40
|
+
return createRequestHandler({
|
|
41
|
+
routes: manifest.routes,
|
|
42
|
+
loader: createProdLoader({ serverDir, manifest }),
|
|
43
|
+
publicRoot: join(root, "public"),
|
|
44
|
+
assetsRoot: join(root, OUTPUT_DIR, "client", "assets"),
|
|
45
|
+
clientAssets: manifest.client,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Orchestrate `smaoog start`: serve the built SSR app from `.smaoog`. No Vite,
|
|
50
|
+
// no `src` — production output only.
|
|
51
|
+
export async function startProd({ root = process.cwd(), port = 3000 } = {}) {
|
|
52
|
+
const requestHandler = await createProdRequestHandler({ root });
|
|
53
|
+
const server = await startServer({ port, requestHandler });
|
|
54
|
+
return {
|
|
55
|
+
server,
|
|
56
|
+
async close() {
|
|
57
|
+
await new Promise((resolve) => server.close(resolve));
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/tailwind.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Optionally pull in the app's Tailwind Vite plugin. It is a starter default,
|
|
5
|
+
// not a framework dependency: if `@tailwindcss/vite` isn't installed in the app,
|
|
6
|
+
// dev and build proceed without it. Resolving from the app's package.json (not
|
|
7
|
+
// smaoog's) keeps Tailwind an app-owned choice, matching how the scaffold ships
|
|
8
|
+
// it as a dependency of the generated app rather than of the framework.
|
|
9
|
+
export async function loadTailwindPlugin(root) {
|
|
10
|
+
const requireFromApp = createRequire(join(root, "package.json"));
|
|
11
|
+
let resolved;
|
|
12
|
+
try {
|
|
13
|
+
resolved = requireFromApp.resolve("@tailwindcss/vite");
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const mod = await import(resolved);
|
|
18
|
+
const plugin = mod.default;
|
|
19
|
+
return typeof plugin === "function" ? [plugin()] : [];
|
|
20
|
+
}
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createServer as createViteDevServer } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { serverOnlyDevPlugin } from "./server-only.js";
|
|
5
|
+
import { loadTailwindPlugin } from "./tailwind.js";
|
|
6
|
+
|
|
7
|
+
// Create smaoog's internal Vite dev server. This is the module boundary:
|
|
8
|
+
// route modules, documents, and React pages will be loaded through it in
|
|
9
|
+
// later phases. It is never exposed as a user-facing command.
|
|
10
|
+
//
|
|
11
|
+
// - middlewareMode keeps Vite from owning an HTTP server; smaoog's node:http
|
|
12
|
+
// server stays in control.
|
|
13
|
+
// - appType "custom" disables Vite's built-in HTML/SPA handling.
|
|
14
|
+
// - configFile false means the user never needs a vite.config.
|
|
15
|
+
// - plugins [react()] gives the automatic JSX runtime (no `import React`)
|
|
16
|
+
// and Fast Refresh, whose preamble (see ReactRefresh in index.js) pulls
|
|
17
|
+
// Vite's HMR client into the browser.
|
|
18
|
+
// - HMR rides on the shared `server` when one is passed (the `smaoog dev`
|
|
19
|
+
// path), so Fast Refresh connects over the page's own origin/port — no
|
|
20
|
+
// second port and proxy-friendly. Without a server (tests, one-off module
|
|
21
|
+
// loads) HMR stays off; otherwise every Vite instance would bind the default
|
|
22
|
+
// HMR port (24678) and collide.
|
|
23
|
+
export async function createViteServer({ root = process.cwd(), logLevel, server } = {}) {
|
|
24
|
+
return createViteDevServer({
|
|
25
|
+
root,
|
|
26
|
+
configFile: false,
|
|
27
|
+
appType: "custom",
|
|
28
|
+
logLevel,
|
|
29
|
+
// react() for the JSX runtime + Fast Refresh; the server-only guard keeps
|
|
30
|
+
// .server.* modules out of browser requests (stripping a route's
|
|
31
|
+
// handler-only imports, rejecting any other browser import of one); Tailwind
|
|
32
|
+
// (when the app installs it) processes `@import "tailwindcss"` so dev matches
|
|
33
|
+
// the build, instead of the browser fetching a literal /src/tailwindcss 404.
|
|
34
|
+
plugins: [
|
|
35
|
+
react(),
|
|
36
|
+
serverOnlyDevPlugin({ routesDir: join(root, "src", "routes") }),
|
|
37
|
+
...(await loadTailwindPlugin(root)),
|
|
38
|
+
],
|
|
39
|
+
// The app owns React; smaoog also depends on it. Dedupe so a single React
|
|
40
|
+
// instance is used — duplicate copies break SSR/hydration later.
|
|
41
|
+
resolve: {
|
|
42
|
+
dedupe: ["react", "react-dom"],
|
|
43
|
+
},
|
|
44
|
+
server: {
|
|
45
|
+
middlewareMode: true,
|
|
46
|
+
// Share smaoog's HTTP server for the HMR WebSocket when one is provided;
|
|
47
|
+
// the Fast Refresh client then connects to the same host/port as the page.
|
|
48
|
+
// With no server, disable HMR entirely (`hmr: false` + `ws: false`) so a
|
|
49
|
+
// bare Vite instance never binds the standalone HMR port (24678).
|
|
50
|
+
...(server ? { hmr: { server } } : { hmr: false, ws: false }),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Load a module through Vite's SSR pipeline, transforming TS/TSX/JSX into
|
|
56
|
+
// runnable ESM. `id` is a path relative to the Vite root, e.g. "/index.tsx".
|
|
57
|
+
export function loadModule(vite, id) {
|
|
58
|
+
return vite.ssrLoadModule(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Drop a previously loaded module from Vite's graph so the next loadModule
|
|
62
|
+
// re-reads it from disk. Returns whether a cached module was found.
|
|
63
|
+
export async function invalidateModule(vite, id) {
|
|
64
|
+
const mod = await vite.moduleGraph.getModuleByUrl(id, true);
|
|
65
|
+
if (mod) {
|
|
66
|
+
vite.moduleGraph.invalidateModule(mod);
|
|
67
|
+
}
|
|
68
|
+
return Boolean(mod);
|
|
69
|
+
}
|