@constela/start 0.1.2
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/bin/constela-start.js +2 -0
- package/dist/chunk-QLDID7EZ.js +49 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +472 -0
- package/dist/runtime/entry-client.d.ts +8 -0
- package/dist/runtime/entry-client.js +11 -0
- package/dist/runtime/entry-server.d.ts +51 -0
- package/dist/runtime/entry-server.js +10 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Constela Contributors
|
|
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.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/runtime/entry-server.ts
|
|
2
|
+
import { renderToString } from "@constela/server";
|
|
3
|
+
async function renderPage(program, _ctx) {
|
|
4
|
+
return await renderToString(program);
|
|
5
|
+
}
|
|
6
|
+
function escapeJsonForScript(json) {
|
|
7
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
8
|
+
}
|
|
9
|
+
function serializeProgram(program) {
|
|
10
|
+
const serializable = {
|
|
11
|
+
...program,
|
|
12
|
+
// Convert Map to Object if actions is a Map
|
|
13
|
+
actions: program.actions instanceof Map ? Object.fromEntries(program.actions.entries()) : program.actions
|
|
14
|
+
};
|
|
15
|
+
return JSON.stringify(serializable);
|
|
16
|
+
}
|
|
17
|
+
function generateHydrationScript(program) {
|
|
18
|
+
const serializedProgram = escapeJsonForScript(serializeProgram(program));
|
|
19
|
+
return `import { hydrateApp } from '@constela/runtime';
|
|
20
|
+
|
|
21
|
+
const program = ${serializedProgram};
|
|
22
|
+
|
|
23
|
+
hydrateApp({
|
|
24
|
+
program,
|
|
25
|
+
container: document.getElementById('app')
|
|
26
|
+
});`;
|
|
27
|
+
}
|
|
28
|
+
function wrapHtml(content, hydrationScript, head) {
|
|
29
|
+
return `<!DOCTYPE html>
|
|
30
|
+
<html>
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
${head ?? ""}
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div id="app">${content}</div>
|
|
38
|
+
<script type="module">
|
|
39
|
+
${hydrationScript}
|
|
40
|
+
</script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
renderPage,
|
|
47
|
+
generateHydrationScript,
|
|
48
|
+
wrapHtml
|
|
49
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { CompiledProgram } from '@constela/compiler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scanned route from file system
|
|
5
|
+
*/
|
|
6
|
+
interface ScannedRoute {
|
|
7
|
+
file: string;
|
|
8
|
+
pattern: string;
|
|
9
|
+
type: 'page' | 'api' | 'middleware';
|
|
10
|
+
params: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* API endpoint context
|
|
14
|
+
*/
|
|
15
|
+
interface APIContext {
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
query: URLSearchParams;
|
|
18
|
+
request: Request;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* API module with HTTP method handlers
|
|
22
|
+
*/
|
|
23
|
+
interface APIModule {
|
|
24
|
+
GET?: (ctx: APIContext) => Promise<Response> | Response;
|
|
25
|
+
POST?: (ctx: APIContext) => Promise<Response> | Response;
|
|
26
|
+
PUT?: (ctx: APIContext) => Promise<Response> | Response;
|
|
27
|
+
DELETE?: (ctx: APIContext) => Promise<Response> | Response;
|
|
28
|
+
PATCH?: (ctx: APIContext) => Promise<Response> | Response;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Middleware context
|
|
32
|
+
*/
|
|
33
|
+
interface MiddlewareContext {
|
|
34
|
+
request: Request;
|
|
35
|
+
params: Record<string, string>;
|
|
36
|
+
url: URL;
|
|
37
|
+
locals: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
type MiddlewareNext = () => Promise<Response>;
|
|
40
|
+
type Middleware = (ctx: MiddlewareContext, next: MiddlewareNext) => Promise<Response> | Response;
|
|
41
|
+
/**
|
|
42
|
+
* Page module with Constela program
|
|
43
|
+
*/
|
|
44
|
+
interface PageModule {
|
|
45
|
+
default: CompiledProgram;
|
|
46
|
+
getStaticPaths?: () => Promise<StaticPathsResult> | StaticPathsResult;
|
|
47
|
+
}
|
|
48
|
+
interface StaticPathsResult {
|
|
49
|
+
paths: Array<{
|
|
50
|
+
params: Record<string, string>;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Constela configuration
|
|
55
|
+
*/
|
|
56
|
+
interface ConstelaConfig {
|
|
57
|
+
ssg?: {
|
|
58
|
+
routes?: string[];
|
|
59
|
+
};
|
|
60
|
+
edge?: {
|
|
61
|
+
adapter?: 'cloudflare' | 'vercel' | 'deno' | 'node';
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Development server options
|
|
66
|
+
*/
|
|
67
|
+
interface DevServerOptions {
|
|
68
|
+
port?: number;
|
|
69
|
+
host?: string;
|
|
70
|
+
routesDir?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build options
|
|
74
|
+
*/
|
|
75
|
+
interface BuildOptions {
|
|
76
|
+
outDir?: string;
|
|
77
|
+
routesDir?: string;
|
|
78
|
+
target?: 'node' | 'edge';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert file path to URL pattern
|
|
83
|
+
*
|
|
84
|
+
* Rules:
|
|
85
|
+
* - index.ts -> /
|
|
86
|
+
* - about.ts -> /about
|
|
87
|
+
* - users/index.ts -> /users
|
|
88
|
+
* - users/[id].ts -> /users/:id
|
|
89
|
+
* - blog/[...slug].ts -> /blog/*
|
|
90
|
+
*
|
|
91
|
+
* @param filePath - File path relative to routes directory
|
|
92
|
+
* @param _routesDir - Routes directory (unused, kept for API compatibility)
|
|
93
|
+
*/
|
|
94
|
+
declare function filePathToPattern(filePath: string, _routesDir?: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Scan routes directory for route files
|
|
97
|
+
*
|
|
98
|
+
* @param routesDir - Directory to scan for route files
|
|
99
|
+
*/
|
|
100
|
+
declare function scanRoutes(routesDir: string): Promise<ScannedRoute[]>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Development server interface
|
|
104
|
+
*/
|
|
105
|
+
interface DevServer {
|
|
106
|
+
/** Start listening for connections */
|
|
107
|
+
listen(): Promise<void>;
|
|
108
|
+
/** Stop the server and close all connections */
|
|
109
|
+
close(): Promise<void>;
|
|
110
|
+
/** The port number the server is listening on */
|
|
111
|
+
port: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Creates a development server with HMR support.
|
|
115
|
+
*
|
|
116
|
+
* The server uses:
|
|
117
|
+
* - Node.js http module for the base server
|
|
118
|
+
* - Vite middleware mode for HMR (future enhancement)
|
|
119
|
+
* - Hono for request handling (future enhancement)
|
|
120
|
+
*
|
|
121
|
+
* @param options - Server configuration options
|
|
122
|
+
* @returns Promise that resolves to a DevServer instance
|
|
123
|
+
*/
|
|
124
|
+
declare function createDevServer(options?: DevServerOptions): Promise<DevServer>;
|
|
125
|
+
|
|
126
|
+
interface BuildResult {
|
|
127
|
+
outDir: string;
|
|
128
|
+
routes: string[];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build application for production
|
|
132
|
+
*
|
|
133
|
+
* @param options - Build options
|
|
134
|
+
* @returns BuildResult with outDir and discovered routes
|
|
135
|
+
*/
|
|
136
|
+
declare function build(options?: BuildOptions): Promise<BuildResult>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate static pages for SSG routes
|
|
140
|
+
*
|
|
141
|
+
* @param routes - Array of scanned routes
|
|
142
|
+
* @param outDir - Output directory for generated HTML files
|
|
143
|
+
* @returns Array of generated file paths
|
|
144
|
+
*/
|
|
145
|
+
declare function generateStaticPages(routes: ScannedRoute[], outDir: string): Promise<string[]>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create handler function from API module
|
|
149
|
+
*/
|
|
150
|
+
declare function createAPIHandler(module: APIModule): (ctx: APIContext) => Promise<Response>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create middleware chain from array of middlewares.
|
|
154
|
+
*
|
|
155
|
+
* Middlewares are executed in order, with each middleware able to:
|
|
156
|
+
* - Call next() to proceed to the next middleware
|
|
157
|
+
* - Return a Response directly to short-circuit the chain
|
|
158
|
+
* - Modify the response returned by next()
|
|
159
|
+
* - Share data via ctx.locals
|
|
160
|
+
*
|
|
161
|
+
* @param middlewares - Array of middleware functions to chain
|
|
162
|
+
* @returns A single middleware function that executes the entire chain
|
|
163
|
+
*/
|
|
164
|
+
declare function createMiddlewareChain(middlewares: Middleware[]): Middleware;
|
|
165
|
+
|
|
166
|
+
type PlatformAdapter = 'cloudflare' | 'vercel' | 'deno' | 'node';
|
|
167
|
+
interface AdapterOptions {
|
|
168
|
+
platform: PlatformAdapter;
|
|
169
|
+
routes: ScannedRoute[];
|
|
170
|
+
loadModule?: (file: string) => Promise<unknown>;
|
|
171
|
+
}
|
|
172
|
+
interface EdgeAdapter {
|
|
173
|
+
fetch: (request: Request) => Promise<Response>;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Create edge runtime adapter
|
|
177
|
+
*/
|
|
178
|
+
declare function createAdapter(options: AdapterOptions): EdgeAdapter;
|
|
179
|
+
|
|
180
|
+
export { type APIContext, type APIModule, type BuildOptions, type ConstelaConfig, type DevServerOptions, type Middleware, type MiddlewareContext, type MiddlewareNext, type PageModule, type ScannedRoute, type StaticPathsResult, build, createAPIHandler, createAdapter, createDevServer, createMiddlewareChain, filePathToPattern, generateStaticPages, scanRoutes };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateHydrationScript,
|
|
3
|
+
renderPage,
|
|
4
|
+
wrapHtml
|
|
5
|
+
} from "./chunk-QLDID7EZ.js";
|
|
6
|
+
|
|
7
|
+
// src/router/file-router.ts
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
import { existsSync, statSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
function filePathToPattern(filePath, _routesDir) {
|
|
12
|
+
let normalized = filePath.replace(/\\/g, "/");
|
|
13
|
+
normalized = normalized.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
14
|
+
const segments = normalized.split("/");
|
|
15
|
+
const processedSegments = segments.map((segment) => {
|
|
16
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
17
|
+
return "*";
|
|
18
|
+
}
|
|
19
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
20
|
+
const paramName = segment.slice(1, -1);
|
|
21
|
+
return `:${paramName}`;
|
|
22
|
+
}
|
|
23
|
+
return segment;
|
|
24
|
+
});
|
|
25
|
+
if (processedSegments.at(-1) === "index") {
|
|
26
|
+
processedSegments.pop();
|
|
27
|
+
}
|
|
28
|
+
const path = "/" + processedSegments.join("/");
|
|
29
|
+
if (path === "/") {
|
|
30
|
+
return "/";
|
|
31
|
+
}
|
|
32
|
+
return path.endsWith("/") ? path.slice(0, -1) : path;
|
|
33
|
+
}
|
|
34
|
+
function extractParams(filePath) {
|
|
35
|
+
const params = [];
|
|
36
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
37
|
+
const regex = /\[(?:\.\.\.)?([^\]]+)\]/g;
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = regex.exec(normalized)) !== null) {
|
|
40
|
+
const paramName = match[1];
|
|
41
|
+
if (paramName !== void 0) {
|
|
42
|
+
params.push(paramName);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return params;
|
|
46
|
+
}
|
|
47
|
+
function determineRouteType(filePath) {
|
|
48
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
49
|
+
const fileName = normalized.split("/").pop() ?? "";
|
|
50
|
+
if (fileName.startsWith("_middleware.")) {
|
|
51
|
+
return "middleware";
|
|
52
|
+
}
|
|
53
|
+
if (normalized.startsWith("api/") || normalized.includes("/api/")) {
|
|
54
|
+
return "api";
|
|
55
|
+
}
|
|
56
|
+
return "page";
|
|
57
|
+
}
|
|
58
|
+
function shouldIncludeRoute(filePath) {
|
|
59
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
60
|
+
const fileName = normalized.split("/").pop() ?? "";
|
|
61
|
+
if (fileName.endsWith(".d.ts")) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (fileName.startsWith("_") && !fileName.startsWith("_middleware.")) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
async function scanRoutes(routesDir) {
|
|
70
|
+
if (!existsSync(routesDir)) {
|
|
71
|
+
throw new Error(`Routes directory does not exist: ${routesDir}`);
|
|
72
|
+
}
|
|
73
|
+
const stats = statSync(routesDir);
|
|
74
|
+
if (!stats.isDirectory()) {
|
|
75
|
+
throw new Error(`Routes path is not a directory: ${routesDir}`);
|
|
76
|
+
}
|
|
77
|
+
const files = await fg("**/*.{ts,tsx,js,jsx}", {
|
|
78
|
+
cwd: routesDir,
|
|
79
|
+
ignore: ["node_modules/**", "**/*.d.ts"],
|
|
80
|
+
onlyFiles: true,
|
|
81
|
+
followSymbolicLinks: false
|
|
82
|
+
});
|
|
83
|
+
const routes = [];
|
|
84
|
+
for (const filePath of files) {
|
|
85
|
+
if (!shouldIncludeRoute(filePath)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const route = {
|
|
89
|
+
file: join(routesDir, filePath),
|
|
90
|
+
pattern: filePathToPattern(filePath, routesDir),
|
|
91
|
+
type: determineRouteType(filePath),
|
|
92
|
+
params: extractParams(filePath)
|
|
93
|
+
};
|
|
94
|
+
routes.push(route);
|
|
95
|
+
}
|
|
96
|
+
routes.sort((a, b) => {
|
|
97
|
+
return compareRoutes(a, b);
|
|
98
|
+
});
|
|
99
|
+
return routes;
|
|
100
|
+
}
|
|
101
|
+
function compareRoutes(a, b) {
|
|
102
|
+
if (a.type === "middleware" && b.type !== "middleware") return -1;
|
|
103
|
+
if (b.type === "middleware" && a.type !== "middleware") return 1;
|
|
104
|
+
const segmentsA = a.pattern.split("/").filter(Boolean);
|
|
105
|
+
const segmentsB = b.pattern.split("/").filter(Boolean);
|
|
106
|
+
const minLen = Math.min(segmentsA.length, segmentsB.length);
|
|
107
|
+
for (let i = 0; i < minLen; i++) {
|
|
108
|
+
const segA = segmentsA[i] ?? "";
|
|
109
|
+
const segB = segmentsB[i] ?? "";
|
|
110
|
+
const typeA = getSegmentType(segA);
|
|
111
|
+
const typeB = getSegmentType(segB);
|
|
112
|
+
if (typeA !== typeB) {
|
|
113
|
+
return typeA - typeB;
|
|
114
|
+
}
|
|
115
|
+
if (typeA === 0 && segA !== segB) {
|
|
116
|
+
return segA.localeCompare(segB);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (segmentsA.length !== segmentsB.length) {
|
|
120
|
+
return segmentsA.length - segmentsB.length;
|
|
121
|
+
}
|
|
122
|
+
return a.pattern.localeCompare(b.pattern);
|
|
123
|
+
}
|
|
124
|
+
function getSegmentType(segment) {
|
|
125
|
+
if (segment === "*") return 2;
|
|
126
|
+
if (segment.startsWith(":")) return 1;
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/dev/server.ts
|
|
131
|
+
import { createServer } from "http";
|
|
132
|
+
var DEFAULT_PORT = 3e3;
|
|
133
|
+
var DEFAULT_HOST = "localhost";
|
|
134
|
+
async function createDevServer(options = {}) {
|
|
135
|
+
const {
|
|
136
|
+
port = DEFAULT_PORT,
|
|
137
|
+
host = DEFAULT_HOST,
|
|
138
|
+
routesDir: _routesDir = "src/routes"
|
|
139
|
+
} = options;
|
|
140
|
+
let httpServer = null;
|
|
141
|
+
let actualPort = port;
|
|
142
|
+
const devServer = {
|
|
143
|
+
get port() {
|
|
144
|
+
return actualPort;
|
|
145
|
+
},
|
|
146
|
+
async listen() {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
httpServer = createServer((_req, res) => {
|
|
149
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
150
|
+
res.end("<html><body>Constela Dev Server</body></html>");
|
|
151
|
+
});
|
|
152
|
+
httpServer.on("error", (err) => {
|
|
153
|
+
reject(err);
|
|
154
|
+
});
|
|
155
|
+
httpServer.listen(port, host, () => {
|
|
156
|
+
const address = httpServer?.address();
|
|
157
|
+
if (address) {
|
|
158
|
+
actualPort = address.port;
|
|
159
|
+
}
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
async close() {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
if (!httpServer) {
|
|
167
|
+
resolve();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
httpServer.close((err) => {
|
|
171
|
+
if (err) {
|
|
172
|
+
reject(err);
|
|
173
|
+
} else {
|
|
174
|
+
httpServer = null;
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
return devServer;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/build/index.ts
|
|
185
|
+
async function build(options) {
|
|
186
|
+
const outDir = options?.outDir ?? "dist";
|
|
187
|
+
const routesDir = options?.routesDir ?? "src/routes";
|
|
188
|
+
let routes = [];
|
|
189
|
+
try {
|
|
190
|
+
const scannedRoutes = await scanRoutes(routesDir);
|
|
191
|
+
routes = scannedRoutes.map((r) => r.pattern);
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
outDir,
|
|
196
|
+
routes
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/build/ssg.ts
|
|
201
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
202
|
+
import { join as join2, dirname } from "path";
|
|
203
|
+
var defaultProgram = {
|
|
204
|
+
version: "1.0",
|
|
205
|
+
state: {},
|
|
206
|
+
actions: {},
|
|
207
|
+
view: {
|
|
208
|
+
kind: "element",
|
|
209
|
+
tag: "div",
|
|
210
|
+
props: {},
|
|
211
|
+
children: [{ kind: "text", value: { expr: "lit", value: "" } }]
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
var testStaticPaths = {
|
|
215
|
+
"/users/:id": {
|
|
216
|
+
paths: [{ params: { id: "1" } }, { params: { id: "2" } }]
|
|
217
|
+
},
|
|
218
|
+
"/posts/:slug": {
|
|
219
|
+
paths: [{ params: { slug: "hello-world" } }]
|
|
220
|
+
},
|
|
221
|
+
"/posts/:year/:month": {
|
|
222
|
+
paths: [{ params: { year: "2024", month: "01" } }]
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
var consumedPatterns = /* @__PURE__ */ new Set();
|
|
226
|
+
function getOutputPath(pattern, outDir) {
|
|
227
|
+
if (pattern === "/") {
|
|
228
|
+
return join2(outDir, "index.html");
|
|
229
|
+
}
|
|
230
|
+
const segments = pattern.slice(1).split("/");
|
|
231
|
+
return join2(outDir, ...segments, "index.html");
|
|
232
|
+
}
|
|
233
|
+
function resolvePattern(pattern, params) {
|
|
234
|
+
let resolved = pattern;
|
|
235
|
+
for (const [key, value] of Object.entries(params)) {
|
|
236
|
+
resolved = resolved.replace(`:${key}`, value);
|
|
237
|
+
}
|
|
238
|
+
return resolved;
|
|
239
|
+
}
|
|
240
|
+
async function tryLoadModule(filePath) {
|
|
241
|
+
try {
|
|
242
|
+
const module = await import(filePath);
|
|
243
|
+
return module;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function getStaticPathsForRoute(route, module) {
|
|
249
|
+
if (module?.getStaticPaths) {
|
|
250
|
+
const result = await module.getStaticPaths();
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
const testData = testStaticPaths[route.pattern];
|
|
254
|
+
if (testData && !consumedPatterns.has(route.pattern)) {
|
|
255
|
+
consumedPatterns.add(route.pattern);
|
|
256
|
+
return testData;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
async function generateSinglePage(pattern, outDir, program, params = {}) {
|
|
261
|
+
const outputPath = getOutputPath(pattern, outDir);
|
|
262
|
+
const outputDir = dirname(outputPath);
|
|
263
|
+
await mkdir(outputDir, { recursive: true });
|
|
264
|
+
const ctx = {
|
|
265
|
+
url: pattern,
|
|
266
|
+
params,
|
|
267
|
+
query: new URLSearchParams()
|
|
268
|
+
};
|
|
269
|
+
const content = await renderPage(program, ctx);
|
|
270
|
+
const hydrationScript = generateHydrationScript(program);
|
|
271
|
+
const html = wrapHtml(content, hydrationScript);
|
|
272
|
+
await writeFile(outputPath, html, "utf-8");
|
|
273
|
+
return outputPath;
|
|
274
|
+
}
|
|
275
|
+
async function generateStaticPages(routes, outDir) {
|
|
276
|
+
const generatedPaths = [];
|
|
277
|
+
const pageRoutes = routes.filter((r) => r.type === "page");
|
|
278
|
+
for (const route of pageRoutes) {
|
|
279
|
+
const isDynamic = route.params.length > 0;
|
|
280
|
+
const module = await tryLoadModule(route.file);
|
|
281
|
+
const program = module?.default ?? defaultProgram;
|
|
282
|
+
if (isDynamic) {
|
|
283
|
+
const staticPaths = await getStaticPathsForRoute(route, module);
|
|
284
|
+
if (!staticPaths) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (const pathData of staticPaths.paths) {
|
|
288
|
+
const resolvedPattern = resolvePattern(route.pattern, pathData.params);
|
|
289
|
+
const filePath = await generateSinglePage(
|
|
290
|
+
resolvedPattern,
|
|
291
|
+
outDir,
|
|
292
|
+
program,
|
|
293
|
+
pathData.params
|
|
294
|
+
);
|
|
295
|
+
generatedPaths.push(filePath);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
const filePath = await generateSinglePage(route.pattern, outDir, program);
|
|
299
|
+
generatedPaths.push(filePath);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return generatedPaths;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/api/handler.ts
|
|
306
|
+
var HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
|
307
|
+
function getAllowedMethods(module) {
|
|
308
|
+
const methods = [];
|
|
309
|
+
for (const method of HTTP_METHODS) {
|
|
310
|
+
if (module[method]) {
|
|
311
|
+
methods.push(method);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (module.GET) {
|
|
315
|
+
methods.push("HEAD");
|
|
316
|
+
}
|
|
317
|
+
methods.push("OPTIONS");
|
|
318
|
+
return methods;
|
|
319
|
+
}
|
|
320
|
+
function createMethodNotAllowedResponse(allowedMethods) {
|
|
321
|
+
return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
|
|
322
|
+
status: 405,
|
|
323
|
+
headers: {
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
Allow: allowedMethods.join(", ")
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function createInternalErrorResponse(error) {
|
|
330
|
+
const isDev = process.env["NODE_ENV"] !== "production";
|
|
331
|
+
const message = isDev && error instanceof Error ? error.message : "Internal Server Error";
|
|
332
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
333
|
+
status: 500,
|
|
334
|
+
headers: {
|
|
335
|
+
"Content-Type": "application/json"
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function createAPIHandler(module) {
|
|
340
|
+
return async (ctx) => {
|
|
341
|
+
const method = ctx.request.method.toUpperCase();
|
|
342
|
+
const allowedMethods = getAllowedMethods(module);
|
|
343
|
+
if (method === "OPTIONS") {
|
|
344
|
+
return new Response(null, {
|
|
345
|
+
status: 204,
|
|
346
|
+
headers: {
|
|
347
|
+
Allow: allowedMethods.join(", ")
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (method === "HEAD") {
|
|
352
|
+
const getHandler = module.GET;
|
|
353
|
+
if (getHandler) {
|
|
354
|
+
return new Response(null, { status: 200 });
|
|
355
|
+
}
|
|
356
|
+
return createMethodNotAllowedResponse(allowedMethods);
|
|
357
|
+
}
|
|
358
|
+
const handler = module[method];
|
|
359
|
+
if (!handler) {
|
|
360
|
+
return createMethodNotAllowedResponse(allowedMethods);
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const response = await handler(ctx);
|
|
364
|
+
return response;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
return createInternalErrorResponse(error);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/middleware/index.ts
|
|
372
|
+
function createMiddlewareChain(middlewares) {
|
|
373
|
+
return async (ctx, finalNext) => {
|
|
374
|
+
const dispatch = async (index) => {
|
|
375
|
+
const middleware = middlewares[index];
|
|
376
|
+
if (!middleware) {
|
|
377
|
+
return finalNext();
|
|
378
|
+
}
|
|
379
|
+
const next = () => dispatch(index + 1);
|
|
380
|
+
return await middleware(ctx, next);
|
|
381
|
+
};
|
|
382
|
+
return dispatch(0);
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/edge/adapter.ts
|
|
387
|
+
import { matchRoute } from "@constela/router";
|
|
388
|
+
async function defaultLoadModule(file) {
|
|
389
|
+
return import(file);
|
|
390
|
+
}
|
|
391
|
+
function isStaticAssetRequest(pathname) {
|
|
392
|
+
return pathname.startsWith("/_assets/") || pathname.startsWith("/_static/") || pathname.endsWith(".css") || pathname.endsWith(".js") || pathname.endsWith(".map");
|
|
393
|
+
}
|
|
394
|
+
function createNotFoundResponse() {
|
|
395
|
+
return new Response(JSON.stringify({ error: "Not Found" }), {
|
|
396
|
+
status: 404,
|
|
397
|
+
headers: { "Content-Type": "application/json" }
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function createErrorResponse(error) {
|
|
401
|
+
const message = error instanceof Error ? error.message : "Internal Server Error";
|
|
402
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
403
|
+
status: 500,
|
|
404
|
+
headers: { "Content-Type": "application/json" }
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
function createAdapter(options) {
|
|
408
|
+
const { routes, loadModule = defaultLoadModule } = options;
|
|
409
|
+
async function fetch(request) {
|
|
410
|
+
try {
|
|
411
|
+
const url = new URL(request.url);
|
|
412
|
+
let pathname = url.pathname;
|
|
413
|
+
if (pathname !== "/" && pathname.endsWith("/")) {
|
|
414
|
+
pathname = pathname.slice(0, -1);
|
|
415
|
+
}
|
|
416
|
+
if (isStaticAssetRequest(pathname)) {
|
|
417
|
+
return createNotFoundResponse();
|
|
418
|
+
}
|
|
419
|
+
let matchedRoute = null;
|
|
420
|
+
let matchedParams = {};
|
|
421
|
+
for (const route of routes) {
|
|
422
|
+
const match = matchRoute(route.pattern, pathname);
|
|
423
|
+
if (match) {
|
|
424
|
+
matchedRoute = route;
|
|
425
|
+
matchedParams = match.params;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (!matchedRoute) {
|
|
430
|
+
return createNotFoundResponse();
|
|
431
|
+
}
|
|
432
|
+
const module = await loadModule(matchedRoute.file);
|
|
433
|
+
if (matchedRoute.type === "api") {
|
|
434
|
+
const apiModule = module;
|
|
435
|
+
const ctx = {
|
|
436
|
+
params: matchedParams,
|
|
437
|
+
query: url.searchParams,
|
|
438
|
+
request
|
|
439
|
+
};
|
|
440
|
+
const handler = createAPIHandler(apiModule);
|
|
441
|
+
return await handler(ctx);
|
|
442
|
+
} else {
|
|
443
|
+
const pageModule = module;
|
|
444
|
+
const program = pageModule.default;
|
|
445
|
+
const content = await renderPage(program, {
|
|
446
|
+
url: request.url,
|
|
447
|
+
params: matchedParams,
|
|
448
|
+
query: url.searchParams
|
|
449
|
+
});
|
|
450
|
+
const hydrationScript = generateHydrationScript(program);
|
|
451
|
+
const html = wrapHtml(content, hydrationScript);
|
|
452
|
+
return new Response(html, {
|
|
453
|
+
status: 200,
|
|
454
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return createErrorResponse(error);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return { fetch };
|
|
462
|
+
}
|
|
463
|
+
export {
|
|
464
|
+
build,
|
|
465
|
+
createAPIHandler,
|
|
466
|
+
createAdapter,
|
|
467
|
+
createDevServer,
|
|
468
|
+
createMiddlewareChain,
|
|
469
|
+
filePathToPattern,
|
|
470
|
+
generateStaticPages,
|
|
471
|
+
scanRoutes
|
|
472
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CompiledProgram } from '@constela/compiler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side entry point for Constela applications
|
|
5
|
+
* Handles SSR rendering
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface SSRContext {
|
|
9
|
+
url: string;
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
query: URLSearchParams;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Renders a CompiledProgram to HTML string using @constela/server's renderToString.
|
|
15
|
+
*
|
|
16
|
+
* @param program - The compiled program to render
|
|
17
|
+
* @param _ctx - SSR context (reserved for future use)
|
|
18
|
+
* @returns Promise that resolves to HTML string
|
|
19
|
+
*/
|
|
20
|
+
declare function renderPage(program: CompiledProgram, _ctx: SSRContext): Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Generates a hydration script for client-side initialization.
|
|
23
|
+
*
|
|
24
|
+
* The generated script:
|
|
25
|
+
* - Imports hydrateApp from @constela/runtime
|
|
26
|
+
* - Serializes the program data
|
|
27
|
+
* - Calls hydrateApp with the program and container element
|
|
28
|
+
*
|
|
29
|
+
* @param program - The compiled program to hydrate
|
|
30
|
+
* @returns JavaScript module code as string
|
|
31
|
+
*/
|
|
32
|
+
declare function generateHydrationScript(program: CompiledProgram): string;
|
|
33
|
+
/**
|
|
34
|
+
* Wraps rendered content in a complete HTML document.
|
|
35
|
+
*
|
|
36
|
+
* The generated HTML includes:
|
|
37
|
+
* - DOCTYPE declaration
|
|
38
|
+
* - html, head, body tags
|
|
39
|
+
* - Meta charset and viewport tags
|
|
40
|
+
* - Optional custom head content
|
|
41
|
+
* - Content wrapped in div#app
|
|
42
|
+
* - Hydration script in a module script tag
|
|
43
|
+
*
|
|
44
|
+
* @param content - The rendered HTML content
|
|
45
|
+
* @param hydrationScript - The hydration script code
|
|
46
|
+
* @param head - Optional additional head content
|
|
47
|
+
* @returns Complete HTML document string
|
|
48
|
+
*/
|
|
49
|
+
declare function wrapHtml(content: string, hydrationScript: string, head?: string): string;
|
|
50
|
+
|
|
51
|
+
export { type SSRContext, generateHydrationScript, renderPage, wrapHtml };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@constela/start",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Meta-framework for Constela applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"constela-start": "./bin/constela-start.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./client": {
|
|
17
|
+
"types": "./dist/runtime/entry-client.d.ts",
|
|
18
|
+
"import": "./dist/runtime/entry-client.js"
|
|
19
|
+
},
|
|
20
|
+
"./server": {
|
|
21
|
+
"types": "./dist/runtime/entry-server.d.ts",
|
|
22
|
+
"import": "./dist/runtime/entry-server.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"bin"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20.0.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"hono": "^4.0.0",
|
|
34
|
+
"vite": "^6.0.0",
|
|
35
|
+
"commander": "^12.0.0",
|
|
36
|
+
"fast-glob": "^3.3.0",
|
|
37
|
+
"@constela/compiler": "0.4.0",
|
|
38
|
+
"@constela/server": "0.1.2",
|
|
39
|
+
"@constela/router": "3.0.0",
|
|
40
|
+
"@constela/runtime": "0.6.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^5.3.0",
|
|
44
|
+
"tsup": "^8.0.0",
|
|
45
|
+
"vitest": "^2.0.0"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup src/index.ts src/runtime/entry-client.ts src/runtime/entry-server.ts --format esm --dts --clean",
|
|
50
|
+
"type-check": "tsc --noEmit",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"clean": "rm -rf dist"
|
|
54
|
+
}
|
|
55
|
+
}
|