@constela/start 0.1.2 → 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.
@@ -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((resolve, reject) => {
148
- httpServer = createServer((_req, res) => {
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
- resolve();
359
+ resolve2();
161
360
  });
162
361
  });
163
362
  },
164
363
  async close() {
165
- return new Promise((resolve, reject) => {
364
+ return new Promise((resolve2, reject) => {
166
365
  if (!httpServer) {
167
- resolve();
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
- resolve();
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 join2, dirname } from "path";
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 join2(outDir, "index.html");
427
+ return join4(outDir, "index.html");
229
428
  }
230
429
  const segments = pattern.slice(1).split("/");
231
- return join2(outDir, ...segments, "index.html");
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
  };
@@ -1,8 +1,41 @@
1
+ import { CompiledProgram } from '@constela/compiler';
2
+ import { AppInstance } from '@constela/runtime';
3
+
1
4
  /**
2
5
  * Client-side entry point for Constela applications
3
- * Handles hydration and client-side routing
6
+ * Handles hydration and Escape Hatch mechanism for external library integration.
7
+ */
8
+
9
+ /**
10
+ * Context provided to EscapeHandler mount functions
11
+ */
12
+ interface EscapeContext {
13
+ appInstance: AppInstance;
14
+ getState: (name: string) => unknown;
15
+ setState: (name: string, value: unknown) => void;
16
+ subscribe: (name: string, fn: (value: unknown) => void) => () => void;
17
+ }
18
+ /**
19
+ * Handler for escape hatch elements
20
+ */
21
+ interface EscapeHandler {
22
+ name: string;
23
+ mount: (element: HTMLElement, ctx: EscapeContext) => () => void;
24
+ }
25
+ /**
26
+ * Options for initializing the client application
27
+ */
28
+ interface InitClientOptions {
29
+ program: CompiledProgram;
30
+ container: HTMLElement;
31
+ escapeHandlers?: EscapeHandler[];
32
+ }
33
+ /**
34
+ * Initialize the client application with hydration and escape hatch support.
35
+ *
36
+ * @param options - Configuration options
37
+ * @returns AppInstance for controlling the application
4
38
  */
5
- declare function hydrate(): void;
6
- declare function mount(_element: HTMLElement): void;
39
+ declare function initClient(options: InitClientOptions): AppInstance;
7
40
 
8
- export { hydrate, mount };
41
+ export { type EscapeContext, type EscapeHandler, type InitClientOptions, initClient };
@@ -1,11 +1,58 @@
1
1
  // src/runtime/entry-client.ts
2
- function hydrate() {
3
- throw new Error("hydrate is not yet implemented");
4
- }
5
- function mount(_element) {
6
- throw new Error("mount is not yet implemented");
2
+ import { hydrateApp } from "@constela/runtime";
3
+ function initClient(options) {
4
+ const { program, container, escapeHandlers = [] } = options;
5
+ const appInstance = hydrateApp({ program, container });
6
+ const escapeElements = container.querySelectorAll(
7
+ "[data-constela-escape]"
8
+ );
9
+ const handlerMap = /* @__PURE__ */ new Map();
10
+ for (const handler of escapeHandlers) {
11
+ handlerMap.set(handler.name, handler);
12
+ }
13
+ const cleanupFns = [];
14
+ for (const element of escapeElements) {
15
+ const escapeName = element.getAttribute("data-constela-escape") ?? "";
16
+ const handler = handlerMap.get(escapeName);
17
+ if (!handler) {
18
+ continue;
19
+ }
20
+ const escapeContext = {
21
+ appInstance,
22
+ getState: (name) => appInstance.getState(name),
23
+ setState: (name, value) => appInstance.setState(name, value),
24
+ subscribe: (name, fn) => {
25
+ if (typeof appInstance.subscribe === "function") {
26
+ return appInstance.subscribe(name, fn);
27
+ }
28
+ return () => {
29
+ };
30
+ }
31
+ };
32
+ try {
33
+ const cleanup = handler.mount(element, escapeContext);
34
+ cleanupFns.push(cleanup);
35
+ } catch {
36
+ }
37
+ }
38
+ let destroyed = false;
39
+ return {
40
+ destroy() {
41
+ if (destroyed) return;
42
+ destroyed = true;
43
+ for (const cleanup of cleanupFns) {
44
+ cleanup();
45
+ }
46
+ appInstance.destroy();
47
+ },
48
+ setState(name, value) {
49
+ appInstance.setState(name, value);
50
+ },
51
+ getState(name) {
52
+ return appInstance.getState(name);
53
+ }
54
+ };
7
55
  }
8
56
  export {
9
- hydrate,
10
- mount
57
+ initClient
11
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/start",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Meta-framework for Constela applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,10 +34,15 @@
34
34
  "vite": "^6.0.0",
35
35
  "commander": "^12.0.0",
36
36
  "fast-glob": "^3.3.0",
37
+ "unified": "^11.0.0",
38
+ "remark-parse": "^11.0.0",
39
+ "remark-mdx": "^3.0.0",
40
+ "remark-gfm": "^4.0.0",
41
+ "gray-matter": "^4.0.0",
37
42
  "@constela/compiler": "0.4.0",
43
+ "@constela/runtime": "0.7.0",
38
44
  "@constela/server": "0.1.2",
39
- "@constela/router": "3.0.0",
40
- "@constela/runtime": "0.6.0"
45
+ "@constela/router": "4.0.0"
41
46
  },
42
47
  "devDependencies": {
43
48
  "typescript": "^5.3.0",
@@ -46,7 +51,7 @@
46
51
  },
47
52
  "license": "MIT",
48
53
  "scripts": {
49
- "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",
50
55
  "type-check": "tsc --noEmit",
51
56
  "test": "vitest run",
52
57
  "test:watch": "vitest",