@inglorious/ssx 1.7.1 → 1.9.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/README.md CHANGED
@@ -40,6 +40,7 @@ SSX takes your entity-based web apps and generates optimized static HTML with fu
40
40
  - **Optimized builds** - Minified, tree-shaken output
41
41
  - **Source maps** - Debug production like development
42
42
  - **Error boundaries** - Graceful failure handling
43
+ - **Serverless functions** - API routes with Web Fetch API
43
44
 
44
45
  ---
45
46
 
@@ -128,11 +129,17 @@ npm run build
128
129
  # → Static site in dist/
129
130
  ```
130
131
 
131
- ### Deploy
132
+ ### Preview
132
133
 
133
134
  ```bash
134
135
  npm run preview
135
- # → Preview production build
136
+ # → Production server at http://localhost:3000 (serves static files + API routes)
137
+ ```
138
+
139
+ Or use the CLI directly:
140
+
141
+ ```bash
142
+ npx ssx serve
136
143
  ```
137
144
 
138
145
  Deploy `dist/` to:
@@ -486,6 +493,121 @@ title: My Post
486
493
  This is a markdown page.
487
494
  ```
488
495
 
496
+ ### ⚡ Serverless Functions (API Routes)
497
+
498
+ SSX supports serverless functions for dynamic API endpoints. Create files in `src/api/` that export HTTP method handlers:
499
+
500
+ ```
501
+ src/api/
502
+ ├── posts.js → /api/posts
503
+ ├── posts/
504
+ │ └── [id].js → /api/posts/:id (alternative structure)
505
+ └── users.js → /api/users
506
+ ```
507
+
508
+ #### Basic Example
509
+
510
+ ```javascript
511
+ // src/api/posts.js
512
+ export async function GET(request) {
513
+ const posts = [
514
+ { id: 1, title: "Hello World" },
515
+ { id: 2, title: "My Second Post" },
516
+ ]
517
+
518
+ return Response.json(posts)
519
+ }
520
+
521
+ export async function POST(request) {
522
+ const body = await request.json()
523
+ // Save to database...
524
+ return Response.json({ created: body }, { status: 201 })
525
+ }
526
+ ```
527
+
528
+ #### Dynamic Routes
529
+
530
+ For routes like `/api/posts/:id`, export a single handler that parses the URL:
531
+
532
+ ```javascript
533
+ // src/api/posts.js
534
+ import { data } from "./posts-data.js"
535
+
536
+ export async function GET(request) {
537
+ const url = new URL(request.url)
538
+ const segments = url.pathname.split("/").filter(Boolean)
539
+ const id = segments[1] // /api/posts/:id
540
+
541
+ if (id) {
542
+ const post = data.find((post) => post.id === id)
543
+ if (!post) {
544
+ return Response.json({ error: "Not found" }, { status: 404 })
545
+ }
546
+ return Response.json(post)
547
+ }
548
+
549
+ return Response.json(data)
550
+ }
551
+ ```
552
+
553
+ #### Using with Pages
554
+
555
+ Fetch from your API in `routeChange` for client-side navigation, and use direct imports in `load` for build-time:
556
+
557
+ ```javascript
558
+ // src/pages/posts/_slug.js
559
+ import { html } from "@inglorious/web"
560
+
561
+ export const post = {
562
+ async routeChange(entity, { route, params }, api) {
563
+ if (route !== entity.type) return
564
+
565
+ const entityId = entity.id
566
+ const response = await fetch(`/api/posts/${params.slug}`)
567
+ const post = await response.json()
568
+ post.body = renderMarkdown(post.body)
569
+ api.notify(`#${entityId}:dataFetchSuccess`, post)
570
+ },
571
+
572
+ dataFetchSuccess(entity, post) {
573
+ entity.post = post
574
+ },
575
+
576
+ render(entity) {
577
+ return html`<h1>${entity.post?.title}</h1>`
578
+ },
579
+ }
580
+
581
+ // Build-time: import directly (no HTTP overhead)
582
+ export async function load(entity, page) {
583
+ const { data } = await import("../../api/posts.js")
584
+ entity.post = data.find((p) => p.id === page.params.slug)
585
+ }
586
+ ```
587
+
588
+ #### Request/Response API
589
+
590
+ Handlers receive a standard Web `Request` object and should return a `Response`:
591
+
592
+ ```javascript
593
+ export async function GET(request) {
594
+ // Access request properties
595
+ const url = new URL(request.url)
596
+ const search = url.searchParams.get("q")
597
+ const headers = request.headers.get("authorization")
598
+
599
+ // Return JSON response
600
+ return Response.json({ results: [] })
601
+
602
+ // Or custom response
603
+ return new Response("Not found", { status: 404 })
604
+ }
605
+ ```
606
+
607
+ #### Supported Methods
608
+
609
+ Export any of these HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`.
610
+
489
611
  ---
490
612
 
491
613
  ## CLI
@@ -520,12 +642,18 @@ Options:
520
642
  -p, --port <port> Dev server port (default: 3000)
521
643
  ```
522
644
 
523
- ### `preview`
645
+ ### `ssx serve`
524
646
 
525
- Serves the built static site on port 3000 through the `serve` NPM package:
647
+ Serves the production build with static files and API routes:
526
648
 
527
649
  ```bash
528
- pnpm preview
650
+ pnpm ssx serve [options]
651
+
652
+ Options:
653
+ -c, --config <file> Config file (default: "site.config.js")
654
+ -r, --root <dir> Source root directory (default: ".")
655
+ -o, --out <dir> Output directory (default: "dist")
656
+ -p, --port <port> Server port (default: 3000)
529
657
  ```
530
658
 
531
659
  ---
@@ -541,6 +669,8 @@ my-site/
541
669
  │ │ └── posts/
542
670
  │ │ ├── index.js # /posts
543
671
  │ │ └── _id.js # /posts/:id
672
+ │ ├── api/ # Serverless functions
673
+ │ │ └── posts.js # /api/posts
544
674
  │ ├── store/ # Store configuration
545
675
  │ │ └── entities.js # Entity definitions
546
676
  │ └── types/ # Custom entity types (optional)
@@ -737,6 +867,19 @@ await dev({
737
867
  })
738
868
  ```
739
869
 
870
+ ### Production Server API
871
+
872
+ ```javascript
873
+ import { serve } from "@inglorious/ssx/serve"
874
+
875
+ await serve({
876
+ rootDir: ".",
877
+ outDir: "dist",
878
+ port: 3000,
879
+ configFile: "site.config.js",
880
+ })
881
+ ```
882
+
740
883
  ---
741
884
 
742
885
  <!-- ## Examples
@@ -756,7 +899,7 @@ Check out these example projects:
756
899
  - [x] Image optimization
757
900
  - [x] Markdown support
758
901
  - [x] i18n routing and locale-aware client navigation
759
- - [ ] API routes (serverless functions)
902
+ - [x] API routes (serverless functions)
760
903
 
761
904
  ---
762
905
 
package/bin/ssx.js CHANGED
@@ -8,6 +8,7 @@ import { Command } from "commander"
8
8
 
9
9
  import { build } from "../src/build/index.js"
10
10
  import { dev } from "../src/dev/index.js"
11
+ import { serve } from "../src/serve/index.js"
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url)
13
14
  const __dirname = path.dirname(__filename)
@@ -87,6 +88,37 @@ program
87
88
  }
88
89
  })
89
90
 
91
+ program
92
+ .command("serve")
93
+ .description("Serve production build with API routes")
94
+ .option("-c, --config <file>", "config file name", "site.config.js")
95
+ .option("-r, --root <dir>", "root directory", ".")
96
+ .option("-o, --out <dir>", "output directory", "dist")
97
+ .option("-p, --port <port>", "server port", 3000)
98
+ .action(async (options) => {
99
+ const cwd = process.cwd()
100
+ const rootDir = path.resolve(cwd, options.root)
101
+ const configPath = resolveConfigFile(rootDir, options.config)
102
+ const outDir = path.resolve(cwd, options.out)
103
+ const port = Number(options.port)
104
+
105
+ try {
106
+ await serve({
107
+ ...options,
108
+ config: undefined,
109
+ root: undefined,
110
+ out: undefined,
111
+ configPath,
112
+ rootDir,
113
+ outDir,
114
+ port,
115
+ })
116
+ } catch (error) {
117
+ console.error("Server failed:", error)
118
+ process.exit(1)
119
+ }
120
+ })
121
+
90
122
  program.parse()
91
123
 
92
124
  function resolveConfigFile(rootDir, configFile) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Server-Side-X. Xecution? Xperience? Who knows.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -27,6 +27,9 @@
27
27
  },
28
28
  "exports": {
29
29
  ".": "./types/index.d.ts",
30
+ "./dev": "./src/dev/index.js",
31
+ "./build": "./src/build/index.js",
32
+ "./serve": "./src/serve/index.js",
30
33
  "./markdown": "./src/utils/markdown.js",
31
34
  "./i18n": {
32
35
  "types": "./types/i18n.d.ts",
@@ -61,11 +64,10 @@
61
64
  "svgo": "^4.0.0",
62
65
  "vite": "^7.1.3",
63
66
  "vite-plugin-image-optimizer": "^2.0.3",
64
- "@inglorious/web": "4.3.0"
67
+ "@inglorious/web": "4.4.0"
65
68
  },
66
69
  "devDependencies": {
67
70
  "prettier": "^3.6.2",
68
- "serve": "^14.2.1",
69
71
  "vitest": "^1.6.1",
70
72
  "@inglorious/eslint-config": "1.1.2"
71
73
  },
@@ -79,6 +81,6 @@
79
81
  "test": "vitest run",
80
82
  "dev": "node ./bin/ssx.js dev -r ./src/__fixtures__",
81
83
  "build": "node ./bin/ssx.js build -r ./src/__fixtures__",
82
- "preview": "serve dist"
84
+ "preview": "node ./bin/ssx.js serve -r ./src/__fixtures__"
83
85
  }
84
86
  }
package/src/dev/api.js ADDED
@@ -0,0 +1,77 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export const createApiMiddleware = (rootDir, loader, onError) => {
5
+ return async (req, res, next) => {
6
+ const [urlPath] = req.url.split("?")
7
+
8
+ if (!urlPath.startsWith("/api/")) return next()
9
+
10
+ try {
11
+ const apiPath = urlPath.replace("/api/", "")
12
+ const segments = apiPath.split("/")
13
+ const searchPaths = []
14
+
15
+ for (let i = segments.length; i > 0; i--) {
16
+ searchPaths.push(segments.slice(0, i).join("/"))
17
+ }
18
+
19
+ let module = null
20
+
21
+ for (const searchPath of searchPaths) {
22
+ const apiFile = path.join(rootDir, "src", "api", `${searchPath}.js`)
23
+ if (existsSync(apiFile)) {
24
+ module = await loader(apiFile)
25
+ break
26
+ }
27
+ }
28
+
29
+ if (!module) {
30
+ res.statusCode = 404
31
+ res.setHeader("Content-Type", "application/json")
32
+ res.end(JSON.stringify({ error: "Not found" }))
33
+ return
34
+ }
35
+
36
+ const method = req.method.toUpperCase()
37
+
38
+ if (!module[method]) {
39
+ res.statusCode = 405
40
+ res.setHeader("Content-Type", "application/json")
41
+ res.end(JSON.stringify({ error: `Method ${method} not allowed` }))
42
+ return
43
+ }
44
+
45
+ const request = createRequest(req)
46
+ const response = await module[method](request)
47
+
48
+ await sendResponse(res, response)
49
+ } catch (error) {
50
+ onError?.(error)
51
+ next(error)
52
+ }
53
+ }
54
+ }
55
+
56
+ function createRequest(req) {
57
+ const protocol = req.socket.encrypted ? "https" : "http"
58
+ const host = req.headers.host || "localhost"
59
+ const url = `${protocol}://${host}${req.url}`
60
+
61
+ return new Request(url, {
62
+ method: req.method,
63
+ headers: req.headers,
64
+ body: req.method !== "GET" && req.method !== "HEAD" ? req : undefined,
65
+ })
66
+ }
67
+
68
+ async function sendResponse(res, response) {
69
+ res.statusCode = response.status
70
+
71
+ for (const [key, value] of response.headers) {
72
+ res.setHeader(key, value)
73
+ }
74
+
75
+ const body = await response.text()
76
+ res.end(body)
77
+ }
package/src/dev/index.js CHANGED
@@ -8,6 +8,7 @@ import { getPages, matchRoute } from "../router/index.js"
8
8
  import { generateApp } from "../scripts/app.js"
9
9
  import { generateStore } from "../store/index.js"
10
10
  import { loadConfig } from "../utils/config.js"
11
+ import { createApiMiddleware } from "./api.js"
11
12
  import { createViteConfig, virtualFiles } from "./vite-config.js"
12
13
 
13
14
  /**
@@ -36,6 +37,11 @@ export async function dev(options = {}) {
36
37
  const connectServer = connect()
37
38
  connectServer.use(viteServer.middlewares)
38
39
 
40
+ const apiMiddleware = createApiMiddleware(rootDir, loader, (error) =>
41
+ viteServer.ssrFixStacktrace(error),
42
+ )
43
+ connectServer.use(apiMiddleware)
44
+
39
45
  // Add SSR middleware
40
46
  connectServer.use(async (req, res, next) => {
41
47
  const [url] = req.url.split("?")
@@ -44,6 +50,7 @@ export async function dev(options = {}) {
44
50
  // Skip special routes, static files, AND public assets
45
51
  if (
46
52
  url.startsWith("/@") ||
53
+ url.startsWith("/api/") ||
47
54
  url.includes(".") || // Vite handles static files
48
55
  url === "/favicon.ico"
49
56
  ) {
@@ -73,7 +80,7 @@ export async function dev(options = {}) {
73
80
  }
74
81
 
75
82
  // Generate and update the virtual app file BEFORE rendering
76
- const app = generateApp(store, pages, { ...mergedOptions, isDev: true })
83
+ const app = generateApp(pages, { ...mergedOptions, isDev: true })
77
84
  virtualFiles.set("/main.js", app)
78
85
 
79
86
  // Invalidate the virtual module to ensure Vite picks up changes
@@ -22,7 +22,8 @@ export function generateApp(pages, options = {}) {
22
22
  }
23
23
  const routes = [...routesByPattern.values()]
24
24
 
25
- return `import { createDevtools, createStore, mount } from "@inglorious/web"
25
+ return `import "@inglorious/web/hydrate"
26
+ import { createDevtools, createStore, mount } from "@inglorious/web"
26
27
  import { getRoute, router, setRoutes } from "@inglorious/web/router"
27
28
  import { getLocaleFromPath } from "@inglorious/ssx/i18n"
28
29
 
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs"
2
+ import { stat } from "node:fs/promises"
3
+ import http from "node:http"
4
+ import { createRequire } from "node:module"
5
+ import path from "node:path"
6
+ import { pathToFileURL } from "node:url"
7
+
8
+ import connect from "connect"
9
+
10
+ import { createApiMiddleware } from "../dev/api.js"
11
+ import { loadConfig } from "../utils/config.js"
12
+
13
+ const require = createRequire(import.meta.url)
14
+
15
+ /**
16
+ * Starts a production server that serves static files and API routes.
17
+ *
18
+ * @param {Object} options - Configuration options.
19
+ * @returns {Promise<{close: Function}>} A promise that resolves to a server control object.
20
+ */
21
+ export async function serve(options = {}) {
22
+ const config = await loadConfig(options)
23
+
24
+ const mergedOptions = { ...config, ...options }
25
+ const { rootDir = ".", outDir = "dist" } = mergedOptions
26
+
27
+ const { port = 3000 } = mergedOptions.vite?.server ?? {}
28
+
29
+ console.log("🚀 Starting production server...\n")
30
+
31
+ const connectServer = connect()
32
+
33
+ const distPath = path.resolve(process.cwd(), outDir)
34
+ const srcPath = path.resolve(process.cwd(), rootDir, "src")
35
+
36
+ const hasApi = existsSync(path.join(srcPath, "api"))
37
+ if (hasApi) {
38
+ const loader = (p) => import(pathToFileURL(p))
39
+ const apiMiddleware = createApiMiddleware(rootDir, loader)
40
+ connectServer.use(apiMiddleware)
41
+ }
42
+
43
+ connectServer.use(serveStatic(distPath))
44
+
45
+ const server = http.createServer(connectServer)
46
+
47
+ await new Promise((resolve) => server.listen(port, resolve))
48
+
49
+ console.log(`\n✨ Production server running at http://localhost:${port}\n`)
50
+ console.log(`📁 Serving from: ${distPath}\n`)
51
+ if (hasApi) {
52
+ console.log(`⚡ API routes enabled from: ${path.join(srcPath, "api")}\n`)
53
+ }
54
+ console.log("Press Ctrl+C to stop\n")
55
+
56
+ return {
57
+ close: () => server.close(),
58
+ }
59
+ }
60
+
61
+ function serveStatic(root) {
62
+ return async (req, res, next) => {
63
+ const [urlPath] = req.url.split("?")
64
+
65
+ let filePath = path.join(root, urlPath)
66
+
67
+ if (urlPath === "/" || urlPath === "") {
68
+ filePath = path.join(root, "index.html")
69
+ } else if (urlPath.endsWith("/")) {
70
+ filePath = path.join(root, urlPath, "index.html")
71
+ } else {
72
+ const stats = await stat(filePath).catch(() => null)
73
+ if (stats?.isDirectory()) {
74
+ filePath = path.join(filePath, "index.html")
75
+ }
76
+ }
77
+
78
+ if (!existsSync(filePath)) {
79
+ return next()
80
+ }
81
+
82
+ const ext = path.extname(filePath)
83
+ const contentType = getContentType(ext)
84
+
85
+ res.setHeader("Content-Type", contentType)
86
+
87
+ try {
88
+ const content = require("fs").readFileSync(filePath)
89
+ res.end(content)
90
+ } catch {
91
+ next()
92
+ }
93
+ }
94
+ }
95
+
96
+ function getContentType(ext) {
97
+ const types = {
98
+ ".html": "text/html",
99
+ ".css": "text/css",
100
+ ".js": "application/javascript",
101
+ ".mjs": "application/javascript",
102
+ ".json": "application/json",
103
+ ".png": "image/png",
104
+ ".jpg": "image/jpeg",
105
+ ".jpeg": "image/jpeg",
106
+ ".gif": "image/gif",
107
+ ".svg": "image/svg+xml",
108
+ ".ico": "image/x-icon",
109
+ ".webp": "image/webp",
110
+ ".avif": "image/avif",
111
+ ".woff": "font/woff",
112
+ ".woff2": "font/woff2",
113
+ ".ttf": "font/ttf",
114
+ ".eot": "application/vnd.ms-fontobject",
115
+ ".xml": "application/xml",
116
+ ".txt": "text/plain",
117
+ }
118
+
119
+ return types[ext] || "application/octet-stream"
120
+ }