@constela/start 0.2.0 → 0.3.0
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/dist/cli/index.d.ts +38 -0
- package/dist/cli/index.js +47 -0
- package/dist/index.d.ts +51 -1
- package/dist/index.js +212 -10
- package/package.json +5 -5
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
type DevHandler = (options: {
|
|
4
|
+
port: string;
|
|
5
|
+
host?: string;
|
|
6
|
+
}) => Promise<{
|
|
7
|
+
port: number;
|
|
8
|
+
}>;
|
|
9
|
+
type BuildHandler = (options: {
|
|
10
|
+
outDir?: string;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
type StartHandler = (options: {
|
|
13
|
+
port: string;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
port: number;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Set custom dev handler (for testing)
|
|
19
|
+
*/
|
|
20
|
+
declare function setDevHandler(handler: DevHandler): void;
|
|
21
|
+
/**
|
|
22
|
+
* Set custom build handler (for testing)
|
|
23
|
+
*/
|
|
24
|
+
declare function setBuildHandler(handler: BuildHandler): void;
|
|
25
|
+
/**
|
|
26
|
+
* Set custom start handler (for testing)
|
|
27
|
+
*/
|
|
28
|
+
declare function setStartHandler(handler: StartHandler): void;
|
|
29
|
+
/**
|
|
30
|
+
* Creates and configures the CLI program
|
|
31
|
+
*/
|
|
32
|
+
declare function createCLI(): Command;
|
|
33
|
+
/**
|
|
34
|
+
* Main CLI entry point
|
|
35
|
+
*/
|
|
36
|
+
declare function main(): void;
|
|
37
|
+
|
|
38
|
+
export { createCLI, main, setBuildHandler, setDevHandler, setStartHandler };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/cli/index.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
var devHandler = async (options) => {
|
|
4
|
+
console.log("Development server not yet implemented");
|
|
5
|
+
return { port: Number(options.port) };
|
|
6
|
+
};
|
|
7
|
+
var buildHandler = async () => {
|
|
8
|
+
console.log("Build not yet implemented");
|
|
9
|
+
};
|
|
10
|
+
var startHandler = async (options) => {
|
|
11
|
+
console.log("Production server not yet implemented");
|
|
12
|
+
return { port: Number(options.port) };
|
|
13
|
+
};
|
|
14
|
+
function setDevHandler(handler) {
|
|
15
|
+
devHandler = handler;
|
|
16
|
+
}
|
|
17
|
+
function setBuildHandler(handler) {
|
|
18
|
+
buildHandler = handler;
|
|
19
|
+
}
|
|
20
|
+
function setStartHandler(handler) {
|
|
21
|
+
startHandler = handler;
|
|
22
|
+
}
|
|
23
|
+
function createCLI() {
|
|
24
|
+
const program = new Command();
|
|
25
|
+
program.name("constela-start").version("0.1.0").description("Meta-framework for Constela applications");
|
|
26
|
+
program.command("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("-h, --host <host>", "Host address").action(async (options) => {
|
|
27
|
+
await devHandler(options);
|
|
28
|
+
});
|
|
29
|
+
program.command("build").description("Build for production").option("-o, --outDir <outDir>", "Output directory").action(async (options) => {
|
|
30
|
+
await buildHandler(options);
|
|
31
|
+
});
|
|
32
|
+
program.command("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").action(async (options) => {
|
|
33
|
+
await startHandler(options);
|
|
34
|
+
});
|
|
35
|
+
return program;
|
|
36
|
+
}
|
|
37
|
+
function main() {
|
|
38
|
+
const program = createCLI();
|
|
39
|
+
program.parse();
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
createCLI,
|
|
43
|
+
main,
|
|
44
|
+
setBuildHandler,
|
|
45
|
+
setDevHandler,
|
|
46
|
+
setStartHandler
|
|
47
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ interface DevServerOptions {
|
|
|
68
68
|
port?: number;
|
|
69
69
|
host?: string;
|
|
70
70
|
routesDir?: string;
|
|
71
|
+
publicDir?: string;
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
74
|
* Build options
|
|
@@ -123,6 +124,55 @@ interface DevServer {
|
|
|
123
124
|
*/
|
|
124
125
|
declare function createDevServer(options?: DevServerOptions): Promise<DevServer>;
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Static file serving utilities.
|
|
129
|
+
*
|
|
130
|
+
* Provides path validation, MIME type detection, and file resolution
|
|
131
|
+
* for serving static files from the public directory.
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* Result of resolving a static file path
|
|
135
|
+
*/
|
|
136
|
+
interface StaticFileResult {
|
|
137
|
+
exists: boolean;
|
|
138
|
+
filePath: string | null;
|
|
139
|
+
mimeType: string | null;
|
|
140
|
+
error?: 'path_traversal' | 'outside_public';
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Check if a URL pathname is safe from path traversal attacks.
|
|
144
|
+
*
|
|
145
|
+
* Rejects:
|
|
146
|
+
* - Paths containing '..' (decoded)
|
|
147
|
+
* - Double slashes '//'
|
|
148
|
+
* - Null bytes
|
|
149
|
+
* - Backslashes
|
|
150
|
+
* - Hidden files (starting with '.')
|
|
151
|
+
* - Paths not starting with '/'
|
|
152
|
+
* - Empty paths
|
|
153
|
+
*
|
|
154
|
+
* @param pathname - The URL pathname to validate
|
|
155
|
+
* @returns true if the path is safe, false otherwise
|
|
156
|
+
*/
|
|
157
|
+
declare function isPathSafe(pathname: string): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Get the MIME type for a file based on its extension.
|
|
160
|
+
*
|
|
161
|
+
* @param filePath - The file path to check
|
|
162
|
+
* @returns The MIME type string, or 'application/octet-stream' for unknown types
|
|
163
|
+
*/
|
|
164
|
+
declare function getMimeType(filePath: string): string;
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a URL pathname to an absolute file path within the public directory.
|
|
167
|
+
*
|
|
168
|
+
* Performs security validation and checks if the file exists.
|
|
169
|
+
*
|
|
170
|
+
* @param pathname - The URL pathname (e.g., '/favicon.ico')
|
|
171
|
+
* @param publicDir - The absolute path to the public directory
|
|
172
|
+
* @returns StaticFileResult with resolution details
|
|
173
|
+
*/
|
|
174
|
+
declare function resolveStaticFile(pathname: string, publicDir: string): StaticFileResult;
|
|
175
|
+
|
|
126
176
|
interface BuildResult {
|
|
127
177
|
outDir: string;
|
|
128
178
|
routes: string[];
|
|
@@ -177,4 +227,4 @@ interface EdgeAdapter {
|
|
|
177
227
|
*/
|
|
178
228
|
declare function createAdapter(options: AdapterOptions): EdgeAdapter;
|
|
179
229
|
|
|
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 };
|
|
230
|
+
export { type APIContext, type APIModule, type BuildOptions, type ConstelaConfig, type DevServerOptions, type Middleware, type MiddlewareContext, type MiddlewareNext, type PageModule, type ScannedRoute, type StaticFileResult, type StaticPathsResult, build, createAPIHandler, createAdapter, createDevServer, createMiddlewareChain, filePathToPattern, generateStaticPages, getMimeType, isPathSafe, resolveStaticFile, scanRoutes };
|
package/dist/index.js
CHANGED
|
@@ -129,13 +129,187 @@ function getSegmentType(segment) {
|
|
|
129
129
|
|
|
130
130
|
// src/dev/server.ts
|
|
131
131
|
import { createServer } from "http";
|
|
132
|
+
import { createReadStream } from "fs";
|
|
133
|
+
import { join as join3 } from "path";
|
|
134
|
+
|
|
135
|
+
// src/static/index.ts
|
|
136
|
+
import { extname, join as join2, normalize, resolve } from "path";
|
|
137
|
+
import { existsSync as existsSync2, statSync as statSync2 } from "fs";
|
|
138
|
+
var MIME_TYPES = {
|
|
139
|
+
// Images
|
|
140
|
+
".ico": "image/x-icon",
|
|
141
|
+
".png": "image/png",
|
|
142
|
+
".jpg": "image/jpeg",
|
|
143
|
+
".jpeg": "image/jpeg",
|
|
144
|
+
".gif": "image/gif",
|
|
145
|
+
".svg": "image/svg+xml",
|
|
146
|
+
".webp": "image/webp",
|
|
147
|
+
// Fonts
|
|
148
|
+
".woff": "font/woff",
|
|
149
|
+
".woff2": "font/woff2",
|
|
150
|
+
".ttf": "font/ttf",
|
|
151
|
+
".otf": "font/otf",
|
|
152
|
+
".eot": "application/vnd.ms-fontobject",
|
|
153
|
+
// Web assets
|
|
154
|
+
".css": "text/css",
|
|
155
|
+
".js": "text/javascript",
|
|
156
|
+
".json": "application/json",
|
|
157
|
+
".txt": "text/plain",
|
|
158
|
+
".html": "text/html",
|
|
159
|
+
".xml": "application/xml",
|
|
160
|
+
// Other
|
|
161
|
+
".pdf": "application/pdf",
|
|
162
|
+
".mp3": "audio/mpeg",
|
|
163
|
+
".mp4": "video/mp4",
|
|
164
|
+
".webm": "video/webm",
|
|
165
|
+
".map": "application/json"
|
|
166
|
+
};
|
|
167
|
+
var DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
168
|
+
function isPathSafe(pathname) {
|
|
169
|
+
if (!pathname) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (!pathname.startsWith("/")) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (pathname.includes("\\")) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (pathname.includes("//")) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (pathname.includes("\0") || pathname.includes("%00")) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
let decoded;
|
|
185
|
+
try {
|
|
186
|
+
decoded = pathname;
|
|
187
|
+
let prevDecoded = "";
|
|
188
|
+
while (decoded !== prevDecoded) {
|
|
189
|
+
prevDecoded = decoded;
|
|
190
|
+
decoded = decodeURIComponent(decoded);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (decoded.includes("\0")) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (decoded.includes("..")) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const segments = decoded.split("/").filter(Boolean);
|
|
202
|
+
for (const segment of segments) {
|
|
203
|
+
if (segment.startsWith(".")) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
function getMimeType(filePath) {
|
|
210
|
+
const ext = extname(filePath).toLowerCase();
|
|
211
|
+
return MIME_TYPES[ext] ?? DEFAULT_MIME_TYPE;
|
|
212
|
+
}
|
|
213
|
+
function hasObviousAttackPattern(pathname) {
|
|
214
|
+
if (!pathname) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (!pathname.startsWith("/")) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (pathname.includes("\\")) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (pathname.includes("//")) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (pathname.includes("\0") || pathname.includes("%00")) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
let decoded;
|
|
230
|
+
try {
|
|
231
|
+
decoded = pathname;
|
|
232
|
+
let prevDecoded = "";
|
|
233
|
+
while (decoded !== prevDecoded) {
|
|
234
|
+
prevDecoded = decoded;
|
|
235
|
+
decoded = decodeURIComponent(decoded);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (decoded.includes("\0")) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
const segments = decoded.split("/").filter(Boolean);
|
|
244
|
+
for (const segment of segments) {
|
|
245
|
+
if (segment.startsWith(".") && segment !== "..") {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (decoded.startsWith("/..")) {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
function resolveStaticFile(pathname, publicDir) {
|
|
255
|
+
if (hasObviousAttackPattern(pathname)) {
|
|
256
|
+
return {
|
|
257
|
+
exists: false,
|
|
258
|
+
filePath: null,
|
|
259
|
+
mimeType: null,
|
|
260
|
+
error: "path_traversal"
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
let decodedPathname;
|
|
264
|
+
try {
|
|
265
|
+
decodedPathname = decodeURIComponent(pathname);
|
|
266
|
+
} catch {
|
|
267
|
+
return {
|
|
268
|
+
exists: false,
|
|
269
|
+
filePath: null,
|
|
270
|
+
mimeType: null,
|
|
271
|
+
error: "path_traversal"
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const relativePath = decodedPathname.slice(1);
|
|
275
|
+
const resolvedPath = normalize(join2(publicDir, relativePath));
|
|
276
|
+
const absolutePublicDir = resolve(publicDir);
|
|
277
|
+
const publicDirWithSep = absolutePublicDir.endsWith("/") ? absolutePublicDir : absolutePublicDir + "/";
|
|
278
|
+
if (!resolvedPath.startsWith(publicDirWithSep) && resolvedPath !== absolutePublicDir) {
|
|
279
|
+
return {
|
|
280
|
+
exists: false,
|
|
281
|
+
filePath: null,
|
|
282
|
+
mimeType: null,
|
|
283
|
+
error: "outside_public"
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const mimeType = getMimeType(resolvedPath);
|
|
287
|
+
let exists = false;
|
|
288
|
+
if (existsSync2(resolvedPath)) {
|
|
289
|
+
try {
|
|
290
|
+
const stats = statSync2(resolvedPath);
|
|
291
|
+
exists = stats.isFile();
|
|
292
|
+
} catch {
|
|
293
|
+
exists = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
exists,
|
|
298
|
+
filePath: resolvedPath,
|
|
299
|
+
mimeType
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/dev/server.ts
|
|
132
304
|
var DEFAULT_PORT = 3e3;
|
|
133
305
|
var DEFAULT_HOST = "localhost";
|
|
306
|
+
var DEFAULT_PUBLIC_DIR = "public";
|
|
134
307
|
async function createDevServer(options = {}) {
|
|
135
308
|
const {
|
|
136
309
|
port = DEFAULT_PORT,
|
|
137
310
|
host = DEFAULT_HOST,
|
|
138
|
-
routesDir: _routesDir = "src/routes"
|
|
311
|
+
routesDir: _routesDir = "src/routes",
|
|
312
|
+
publicDir = join3(process.cwd(), DEFAULT_PUBLIC_DIR)
|
|
139
313
|
} = options;
|
|
140
314
|
let httpServer = null;
|
|
141
315
|
let actualPort = port;
|
|
@@ -144,8 +318,33 @@ async function createDevServer(options = {}) {
|
|
|
144
318
|
return actualPort;
|
|
145
319
|
},
|
|
146
320
|
async listen() {
|
|
147
|
-
return new Promise((
|
|
148
|
-
httpServer = createServer((
|
|
321
|
+
return new Promise((resolve2, reject) => {
|
|
322
|
+
httpServer = createServer((req, res) => {
|
|
323
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
324
|
+
const pathname = url.pathname;
|
|
325
|
+
const staticResult = resolveStaticFile(pathname, publicDir);
|
|
326
|
+
if (staticResult.error === "path_traversal" || staticResult.error === "outside_public") {
|
|
327
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
328
|
+
res.end("Forbidden");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (staticResult.exists && staticResult.filePath && staticResult.mimeType) {
|
|
332
|
+
res.writeHead(200, { "Content-Type": staticResult.mimeType });
|
|
333
|
+
const stream = createReadStream(staticResult.filePath);
|
|
334
|
+
stream.pipe(res);
|
|
335
|
+
stream.on("error", () => {
|
|
336
|
+
if (!res.headersSent) {
|
|
337
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
338
|
+
}
|
|
339
|
+
res.end("Internal Server Error");
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (staticResult.filePath && !staticResult.exists) {
|
|
344
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
345
|
+
res.end("Not Found");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
149
348
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
150
349
|
res.end("<html><body>Constela Dev Server</body></html>");
|
|
151
350
|
});
|
|
@@ -157,14 +356,14 @@ async function createDevServer(options = {}) {
|
|
|
157
356
|
if (address) {
|
|
158
357
|
actualPort = address.port;
|
|
159
358
|
}
|
|
160
|
-
|
|
359
|
+
resolve2();
|
|
161
360
|
});
|
|
162
361
|
});
|
|
163
362
|
},
|
|
164
363
|
async close() {
|
|
165
|
-
return new Promise((
|
|
364
|
+
return new Promise((resolve2, reject) => {
|
|
166
365
|
if (!httpServer) {
|
|
167
|
-
|
|
366
|
+
resolve2();
|
|
168
367
|
return;
|
|
169
368
|
}
|
|
170
369
|
httpServer.close((err) => {
|
|
@@ -172,7 +371,7 @@ async function createDevServer(options = {}) {
|
|
|
172
371
|
reject(err);
|
|
173
372
|
} else {
|
|
174
373
|
httpServer = null;
|
|
175
|
-
|
|
374
|
+
resolve2();
|
|
176
375
|
}
|
|
177
376
|
});
|
|
178
377
|
});
|
|
@@ -199,7 +398,7 @@ async function build(options) {
|
|
|
199
398
|
|
|
200
399
|
// src/build/ssg.ts
|
|
201
400
|
import { mkdir, writeFile } from "fs/promises";
|
|
202
|
-
import { join as
|
|
401
|
+
import { join as join4, dirname } from "path";
|
|
203
402
|
var defaultProgram = {
|
|
204
403
|
version: "1.0",
|
|
205
404
|
state: {},
|
|
@@ -225,10 +424,10 @@ var testStaticPaths = {
|
|
|
225
424
|
var consumedPatterns = /* @__PURE__ */ new Set();
|
|
226
425
|
function getOutputPath(pattern, outDir) {
|
|
227
426
|
if (pattern === "/") {
|
|
228
|
-
return
|
|
427
|
+
return join4(outDir, "index.html");
|
|
229
428
|
}
|
|
230
429
|
const segments = pattern.slice(1).split("/");
|
|
231
|
-
return
|
|
430
|
+
return join4(outDir, ...segments, "index.html");
|
|
232
431
|
}
|
|
233
432
|
function resolvePattern(pattern, params) {
|
|
234
433
|
let resolved = pattern;
|
|
@@ -468,5 +667,8 @@ export {
|
|
|
468
667
|
createMiddlewareChain,
|
|
469
668
|
filePathToPattern,
|
|
470
669
|
generateStaticPages,
|
|
670
|
+
getMimeType,
|
|
671
|
+
isPathSafe,
|
|
672
|
+
resolveStaticFile,
|
|
471
673
|
scanRoutes
|
|
472
674
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constela/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Meta-framework for Constela applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"remark-mdx": "^3.0.0",
|
|
40
40
|
"remark-gfm": "^4.0.0",
|
|
41
41
|
"gray-matter": "^4.0.0",
|
|
42
|
-
"@constela/runtime": "0.7.0",
|
|
43
|
-
"@constela/router": "4.0.0",
|
|
44
42
|
"@constela/compiler": "0.4.0",
|
|
45
|
-
"@constela/
|
|
43
|
+
"@constela/runtime": "0.7.0",
|
|
44
|
+
"@constela/server": "0.1.2",
|
|
45
|
+
"@constela/router": "4.0.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"typescript": "^5.3.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"scripts": {
|
|
54
|
-
"build": "tsup src/index.ts src/runtime/entry-client.ts src/runtime/entry-server.ts --format esm --dts --clean",
|
|
54
|
+
"build": "tsup src/index.ts src/runtime/entry-client.ts src/runtime/entry-server.ts src/cli/index.ts --format esm --dts --clean",
|
|
55
55
|
"type-check": "tsc --noEmit",
|
|
56
56
|
"test": "vitest run",
|
|
57
57
|
"test:watch": "vitest",
|