xpine 0.0.56 → 0.0.59

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,32 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run *)",
5
+ "Bash(PORT=8888 node ./dist/server/run/prod.js)",
6
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8888/)",
7
+ "Bash(break)",
8
+ "Bash(curl -s -o /dev/null -w \"status=%{http_code}\\\\n\" http://localhost:8888/blog/technology/devops/my-blog-post)",
9
+ "Bash(curl -s http://localhost:8888/blog/technology/devops/my-blog-post)",
10
+ "Bash(curl -s -o /dev/null -w \"status=%{http_code}\\\\n\" http://localhost:8888/blog/preview/some/new/post)",
11
+ "Bash(curl -s http://localhost:8888/blog/preview/some/new/post)",
12
+ "Bash(curl -s -o /dev/null -w \"status=%{http_code}\\\\n\" http://localhost:8888/blog/totally/unknown/path)",
13
+ "Bash(curl -s http://localhost:8888/blog/totally/unknown/path)",
14
+ "Bash(curl -s -o /dev/null -w \"status=%{http_code}\\\\n\" http://localhost:8888/some-totally-random-route-xyz)",
15
+ "Bash(curl -s http://localhost:8888/some-totally-random-route-xyz)",
16
+ "Bash(curl -s http://localhost:8888/some-random-xyz)",
17
+ "Bash(curl -s -o /dev/null -w \"status=%{http_code}\\\\n\" http://localhost:8888/blog/nope)",
18
+ "Bash(curl -s http://localhost:8888/blog/nope)",
19
+ "Bash(PORT=8888 npx playwright test app-build --grep \"multi-segment\")",
20
+ "Bash(PORT=8888 npx playwright test)",
21
+ "Bash(npx eslint *)",
22
+ "Bash(echo \"=== exit: $? ===\")",
23
+ "Bash(git stash *)",
24
+ "Bash(PORT=8888 npx playwright test app-build --grep \"traverse\")",
25
+ "Bash(node -e \"console.log\\(require\\('./node_modules/jsonwebtoken/package.json'\\).version\\)\")",
26
+ "Bash(JWT_PRIVATE_KEY=test-secret-123 node --input-type=module -e ' *)",
27
+ "Bash(node --input-type=module -e ' *)",
28
+ "Bash(CSRF_SECRET='csrf-secret-xyz' node /tmp/csrf-test.mjs)",
29
+ "Bash(CSRF_SECRET='csrf-secret-xyz' node csrf-test.mjs)"
30
+ ]
31
+ }
32
+ }
package/README.md CHANGED
@@ -155,6 +155,47 @@ You can create catch all routes by naming the file \_all\_.(jsx|tsx|js|ts). You
155
155
 
156
156
  You can get the route param in your function with req.params[0], such as how express handles catch all routes.
157
157
 
158
+ ### Multi-segment dynamic routes (`[...slug]`)
159
+
160
+ Catch all (`_all_`) routes register a single Express wildcard, which means they match _every_ path under their prefix — including paths that should not exist. For deeply nested but known dynamic routes (e.g. a blog post at `/blog/technology/devops/my-blog-post`), use a multi-segment dynamic param instead. Name the file `[...slug].(jsx|tsx|js|ts)`; the captured value (which may contain slashes) is available as `req.params.slug`.
161
+
162
+ Instead of a wildcard, XPine registers an **explicit Express route for each slug** returned by the `staticPaths` config function, so unknown paths safely fall through to your 404 page:
163
+
164
+ ```
165
+ // /src/pages/blog/+config.ts
166
+ export default {
167
+ staticPaths() {
168
+ // Slugs can come from a CMS/database
169
+ return [
170
+ { slug: 'technology/devops/my-blog-post' },
171
+ ];
172
+ },
173
+ };
174
+ ```
175
+
176
+ ```
177
+ // /src/pages/blog/[...slug].tsx
178
+ import { PageProps } from 'xpine/dist/types';
179
+
180
+ export default function BlogPost({ req }: PageProps) {
181
+ return <div>{req.params.slug}</div>;
182
+ }
183
+ ```
184
+
185
+ Each slug from `staticPaths` is also statically generated at build time. To allow slugs that are not known at build time (without falling back to an unsafe catch-all), add an `isValid` function to the config. It receives the requested slug and runs at request time; return `false` (the default for unknown slugs) to fall through to the 404 handler:
186
+
187
+ ```
188
+ export default {
189
+ staticPaths() {
190
+ return [{ slug: 'technology/devops/my-blog-post' }];
191
+ },
192
+ // Resolve newly-published slugs without a rebuild
193
+ async isValid(slug, req) {
194
+ return await postExists(slug);
195
+ },
196
+ };
197
+ ```
198
+
158
199
  ### Route specific middleware
159
200
 
160
201
  If you need route specific middleware, e.g. for file uploads, you can specify a `routeMiddleware` function in a config variable in the endpoint file:
@@ -168,6 +209,58 @@ export const config = {
168
209
  }
169
210
  ```
170
211
 
212
+ ### CSRF protection
213
+
214
+ Because authentication is cookie based, state-changing requests (POST/PUT/PATCH/DELETE) are exposed to cross-site request forgery. Enable the built-in guard by setting `csrf` in your `xpine.config.mjs`:
215
+
216
+ ```
217
+ export default {
218
+ csrf: true,
219
+ }
220
+ ```
221
+
222
+ It uses a stateless **signed double-submit cookie**. On safe requests (GET/HEAD/OPTIONS) it sets a `csrfToken` cookie (readable by client JS). On state-changing requests it requires that same token to be echoed back in an `x-csrf-token` header (or a `_csrf` form field) — a cross-site attacker can make the browser send the cookie but cannot read it or set the header, so forged requests are rejected with a 403. The token is HMAC-signed so it can't be forged even by an attacker who can plant a cookie.
223
+
224
+ Set a secret via the `CSRF_SECRET` env var (it falls back to `JWT_PRIVATE_KEY`). The server fails to start if neither is set.
225
+
226
+ For `fetch`/Alpine requests, read the cookie and send it back:
227
+ ```
228
+ const token = document.cookie.split('; ').find(c => c.startsWith('csrfToken='))?.split('=')[1];
229
+ await fetch('/api/thing', {
230
+ method: 'POST',
231
+ headers: { 'x-csrf-token': decodeURIComponent(token) },
232
+ });
233
+ ```
234
+ For server-rendered HTML forms, the token is available on `res.locals.csrfToken` (pages receive `res`); render it as a hidden `<input name="_csrf">`.
235
+
236
+ You can customize the cookie/header/field names, cookie attributes, and skip specific path prefixes (e.g. signed webhooks):
237
+ ```
238
+ export default {
239
+ csrf: {
240
+ headerName: 'x-csrf-token',
241
+ cookie: { sameSite: 'strict', secure: true },
242
+ ignorePaths: ['/api/webhooks/'],
243
+ },
244
+ }
245
+ ```
246
+
247
+ ### HTML escaping (XSS protection)
248
+
249
+ Values interpolated into JSX are HTML-escaped by default, so rendering user input is safe:
250
+ ```
251
+ // req.params.slug = '<img src=x onerror=alert(1)>'
252
+ <div>{req.params.slug}</div> // renders &lt;img src=x onerror=alert(1)&gt;
253
+ ```
254
+ Nested components and elements are not re-escaped, and attribute values are escaped too. Text inside `<script>`/`<style>` is left raw (it isn't HTML) — never interpolate untrusted data there.
255
+
256
+ If you have trusted HTML that should render as-is, opt out explicitly with `raw()`:
257
+ ```
258
+ import { raw } from 'xpine';
259
+
260
+ <div>{raw(trustedHtmlString)}</div>
261
+ ```
262
+ Only use `raw()` with HTML you control — never with user input.
263
+
171
264
  ### Static Site Generation
172
265
 
173
266
  Generate path specific static pages by specifying in the config of either the page's file, such as `/src/pages/about.tsx` with a config export:
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './src/util/env';
4
4
  export * from './src/util/get-config';
5
5
  export * from './src/scripts/build';
6
6
  export * from './src/auth';
7
+ export * from './src/csrf';
7
8
  export * from './src/util/html';
8
9
  export * from './src/context';
9
10
  export * from './src/util/web';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -326,6 +326,9 @@ var regex_default = {
326
326
  configFile: /\+config\.[tj]sx?/g,
327
327
  dynamicRoutes: /(\[)(.*?)(\])/g,
328
328
  isDynamicRoute: /\[(.*)\]/g,
329
+ // Multi-segment (slash-containing) dynamic route, e.g. [...slug]
330
+ spreadRoute: /\[\.\.\.(.*?)\]/g,
331
+ isSpreadRoute: /\[\.\.\..*?\]/,
329
332
  endsWithTSX: /\.tsx$/,
330
333
  endsWithJSX: /\.jsx$/,
331
334
  endsWithJs: /\.js$/,
@@ -467,6 +470,12 @@ import { globSync } from "glob";
467
470
  // src/auth.ts
468
471
  import jsonwebtoken from "jsonwebtoken";
469
472
  var { verify, sign } = jsonwebtoken;
473
+ var JWT_ALGORITHM = "HS256";
474
+ function getSecret() {
475
+ const secret = process.env.JWT_PRIVATE_KEY;
476
+ if (!secret) throw new Error("JWT_PRIVATE_KEY is not set");
477
+ return secret;
478
+ }
470
479
  async function signUser(email, username) {
471
480
  return new Promise((resolve, reject) => {
472
481
  sign(
@@ -476,11 +485,10 @@ async function signUser(email, username) {
476
485
  username
477
486
  }
478
487
  },
479
- // @ts-ignore
480
- process.env.JWT_PRIVATE_KEY,
481
- { expiresIn: "30d" },
488
+ getSecret(),
489
+ { expiresIn: "30d", algorithm: JWT_ALGORITHM },
482
490
  (err, token) => {
483
- if (err) reject(err);
491
+ if (err) return reject(err);
484
492
  resolve(token);
485
493
  }
486
494
  );
@@ -488,8 +496,8 @@ async function signUser(email, username) {
488
496
  }
489
497
  async function verifyUser(token) {
490
498
  return new Promise((resolve, reject) => {
491
- verify(token, process.env.JWT_PRIVATE_KEY, (err, authorizedData) => {
492
- if (err) reject(err);
499
+ verify(token, getSecret(), { algorithms: [JWT_ALGORITHM] }, (err, authorizedData) => {
500
+ if (err) return reject(err);
493
501
  resolve(authorizedData);
494
502
  });
495
503
  });
@@ -506,8 +514,163 @@ import requestIP from "request-ip";
506
514
  import fs5 from "fs-extra";
507
515
  import path5 from "path";
508
516
  import EventEmitter from "events";
517
+
518
+ // src/csrf.ts
519
+ import crypto from "crypto";
520
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
521
+ function resolveSecret(options) {
522
+ const secret = options?.secret || process.env.CSRF_SECRET || process.env.JWT_PRIVATE_KEY;
523
+ if (!secret) throw new Error("CSRF secret is not set (set CSRF_SECRET or JWT_PRIVATE_KEY)");
524
+ return secret;
525
+ }
526
+ function hmac(value, secret) {
527
+ return crypto.createHmac("sha256", secret).update(value).digest("base64url");
528
+ }
529
+ function issueToken(secret) {
530
+ const random = crypto.randomBytes(32).toString("base64url");
531
+ return `${random}.${hmac(random, secret)}`;
532
+ }
533
+ function isValidToken(token, secret) {
534
+ if (typeof token !== "string" || !token) return false;
535
+ const idx = token.lastIndexOf(".");
536
+ if (idx <= 0 || idx === token.length - 1) return false;
537
+ const random = token.slice(0, idx);
538
+ const signature = token.slice(idx + 1);
539
+ return timingSafeEqual(signature, hmac(random, secret));
540
+ }
541
+ function timingSafeEqual(a, b) {
542
+ const ab = Buffer.from(String(a));
543
+ const bb = Buffer.from(String(b));
544
+ if (ab.length !== bb.length) return false;
545
+ return crypto.timingSafeEqual(ab, bb);
546
+ }
547
+ function readSubmittedToken(req, headerName, fieldName) {
548
+ const header = req.headers[headerName];
549
+ const fromHeader = Array.isArray(header) ? header[0] : header;
550
+ if (fromHeader) return fromHeader;
551
+ if (req.body && typeof req.body[fieldName] === "string") return req.body[fieldName];
552
+ return "";
553
+ }
554
+ function createCsrfMiddleware(options) {
555
+ const secret = resolveSecret(options);
556
+ const cookieName = options?.cookieName || "csrfToken";
557
+ const headerName = (options?.headerName || "x-csrf-token").toLowerCase();
558
+ const fieldName = options?.fieldName || "_csrf";
559
+ const ignorePaths = options?.ignorePaths || [];
560
+ const cookieOptions = {
561
+ httpOnly: false,
562
+ // must be readable by client JS for the double-submit
563
+ sameSite: options?.cookie?.sameSite ?? "lax",
564
+ secure: options?.cookie?.secure ?? process.env.NODE_ENV !== "development",
565
+ path: options?.cookie?.path ?? "/",
566
+ maxAge: options?.cookie?.maxAge
567
+ };
568
+ return function csrfMiddleware(req, res, next) {
569
+ const existing = req.cookies?.[cookieName] || "";
570
+ let token = existing;
571
+ if (!isValidToken(existing, secret)) {
572
+ token = issueToken(secret);
573
+ res.cookie(cookieName, token, cookieOptions);
574
+ }
575
+ res.locals.csrfToken = token;
576
+ if (SAFE_METHODS.has(req.method)) return next();
577
+ if (ignorePaths.some((prefix) => req.path.startsWith(prefix))) return next();
578
+ const submitted = readSubmittedToken(req, headerName, fieldName);
579
+ if (isValidToken(existing, secret) && timingSafeEqual(submitted, existing)) {
580
+ return next();
581
+ }
582
+ res.status(403).json({ message: "Invalid CSRF token" });
583
+ };
584
+ }
585
+
586
+ // src/express.ts
509
587
  var methods = ["get", "post", "put", "patch", "delete"];
510
588
  var isDev = process.env.NODE_ENV === "development";
589
+ function parseRoutePath(route) {
590
+ const slugRoute = route.replace(/[ ]/g, "");
591
+ const foundMethod = methods.find((method) => slugRoute.toUpperCase().endsWith(`.${method.toUpperCase()}`));
592
+ const templateRoute = foundMethod ? slugRoute.slice(0, -(foundMethod.length + 1)) : slugRoute;
593
+ let formattedRouteItem = templateRoute.replace(regex_default.spreadRoute, (_match, name) => ":" + name);
594
+ if (slugRoute.match(regex_default.isDynamicRoute)) {
595
+ for (const match of formattedRouteItem.matchAll(regex_default.dynamicRoutes)) {
596
+ formattedRouteItem = formattedRouteItem.replace(match[0], ":" + match[2]);
597
+ }
598
+ }
599
+ if (formattedRouteItem.match(regex_default.catchAllRoute)) {
600
+ formattedRouteItem = formattedRouteItem.replace(regex_default.catchAllRoute, "/*");
601
+ }
602
+ return {
603
+ foundMethod,
604
+ formattedRouteItem,
605
+ templateRoute,
606
+ isSpread: regex_default.isSpreadRoute.test(templateRoute)
607
+ };
608
+ }
609
+ function spreadConcretePath(templateRoute, entry) {
610
+ return templateRoute.replace(regex_default.spreadRoute, (_match, name) => entry[name] ?? "").replace(regex_default.dynamicRoutes, (_match, _open, name) => entry[name] ?? "").replace(/\/{2,}/g, "/");
611
+ }
612
+ function spreadWildcardPath(templateRoute) {
613
+ return templateRoute.replace(regex_default.spreadRoute, "*").replace(regex_default.dynamicRoutes, (_match, _open, name) => ":" + name);
614
+ }
615
+ function spreadSlugName(templateRoute) {
616
+ const match = templateRoute.match(/\[\.\.\.(.*?)\]/);
617
+ return match ? match[1] : null;
618
+ }
619
+ async function resolveConfig(configFilePaths, componentImport) {
620
+ const baseConfig = configFilePaths ? await getCompleteConfig(configFilePaths, Date.now()) : {};
621
+ return componentImport?.config ? { ...baseConfig, ...componentImport.config } : baseConfig;
622
+ }
623
+ async function renderJSX(componentFn, config2, req, res, urlPath) {
624
+ const data = config2?.data ? await config2.data(req) : null;
625
+ const children = await componentFn({ req, res, data, config: config2, routePath: urlPath });
626
+ const output = config2?.wrapper ? await config2.wrapper({ req, children, config: config2, data, routePath: urlPath }) : children;
627
+ return doctypeHTML + output;
628
+ }
629
+ async function resolveSpreadRoute(templateRoute, config2, originalRoute) {
630
+ let entries = [];
631
+ if (typeof config2?.staticPaths === "function") {
632
+ try {
633
+ entries = await config2.staticPaths() || [];
634
+ } catch (err) {
635
+ console.error(err);
636
+ console.error("Could not resolve staticPaths for spread route", originalRoute);
637
+ }
638
+ }
639
+ return {
640
+ templateRoute,
641
+ entries,
642
+ validator: config2?.isValid || null
643
+ };
644
+ }
645
+ function registerSpreadRoutes(register, spread) {
646
+ const slugName = spreadSlugName(spread.templateRoute);
647
+ for (const entry of spread.entries) {
648
+ register(spreadConcretePath(spread.templateRoute, entry), [
649
+ (req, _res, next) => {
650
+ Object.assign(req.params, entry);
651
+ next();
652
+ }
653
+ ]);
654
+ }
655
+ const { validator } = spread;
656
+ if (validator) {
657
+ register(spreadWildcardPath(spread.templateRoute), [
658
+ async (req, _res, next) => {
659
+ const slug = req.params[0];
660
+ if (slugName) req.params[slugName] = slug;
661
+ try {
662
+ if (await validator(slug, req)) {
663
+ next();
664
+ return;
665
+ }
666
+ } catch (err) {
667
+ console.error(err);
668
+ }
669
+ next("route");
670
+ }
671
+ ]);
672
+ }
673
+ }
511
674
  var OnInitEmitter = class extends EventEmitter {
512
675
  };
513
676
  var onInitEmitter = new OnInitEmitter();
@@ -532,33 +695,12 @@ function getAllBuiltRoutes() {
532
695
  }
533
696
  async function createRouteFunction(route, configFiles) {
534
697
  const isJSX = route.originalRoute.endsWith(".tsx") || route.originalRoute.endsWith(".jsx");
535
- const slugRoute = route.route.replace(/[ ]/g, "");
536
- const foundMethod = methods.find((method) => slugRoute.toUpperCase().endsWith(`.${method.toUpperCase()}`));
537
- const isDynamicRoute = slugRoute.match(regex_default.isDynamicRoute);
538
- let formattedRouteItem = slugRoute;
539
- if (foundMethod) formattedRouteItem = formattedRouteItem.split(".").shift();
540
- if (isDynamicRoute) {
541
- const result = [...formattedRouteItem.matchAll(regex_default.dynamicRoutes)];
542
- for (const match of result) {
543
- formattedRouteItem = formattedRouteItem.replace(match[0], ":" + match[2]);
544
- }
545
- }
546
- const hasCatchAll = formattedRouteItem.match(regex_default.catchAllRoute);
547
- if (hasCatchAll) formattedRouteItem = formattedRouteItem.replace(regex_default.catchAllRoute, "/*");
698
+ const { foundMethod, formattedRouteItem, templateRoute, isSpread } = parseRoutePath(route.route);
548
699
  const componentImport = await import(route.path);
549
700
  const componentFn = isDev ? null : componentImport?.default;
550
- if (componentImport?.config?.onInit) {
551
- await componentImport.config?.onInit();
552
- }
553
- let config2 = {};
701
+ if (componentImport?.config?.onInit) await componentImport.config.onInit();
554
702
  const configFilePaths = getConfigFiles(route.originalRoute, configFiles);
555
- config2 = configFilePaths && await getCompleteConfig(configFilePaths, Date.now());
556
- if (componentImport?.config) {
557
- config2 = {
558
- ...config2,
559
- ...componentImport.config
560
- };
561
- }
703
+ const config2 = await resolveConfig(configFilePaths, componentImport);
562
704
  async function routeFn(req, res) {
563
705
  const urlPath = req?.route?.path || "/";
564
706
  try {
@@ -568,47 +710,37 @@ async function createRouteFunction(route, configFiles) {
568
710
  return;
569
711
  }
570
712
  if (componentFn && !isDev) {
571
- if (isJSX) {
572
- const data = config2?.data ? await config2.data(req) : null;
573
- const originalResult = await componentFn({ req, res, data, routePath: urlPath });
574
- const output = config2?.wrapper ? await config2.wrapper({ req, children: originalResult, config: config2, data, routePath: urlPath }) : originalResult;
575
- res.send(doctypeHTML + output);
576
- } else {
577
- await componentFn(req, res);
578
- }
713
+ if (isJSX) res.send(await renderJSX(componentFn, config2, req, res, urlPath));
714
+ else await componentFn(req, res);
579
715
  return;
580
716
  }
581
717
  await triggerXPineOnLoad(true);
582
- const componentImportDev = await import(route.path + `?cache=${Date.now()}`);
583
- const componentFnDev = componentImportDev.default;
718
+ const devImport = await import(route.path + `?cache=${Date.now()}`);
584
719
  onInitEmitter.emit("triggerOnInit", getAllBuiltRoutes());
585
720
  if (isJSX) {
586
- let config3 = configFilePaths && await getCompleteConfig(configFilePaths, Date.now());
587
- if (componentImportDev?.config) {
588
- config3 = {
589
- ...config3,
590
- ...componentImportDev.config
591
- };
592
- }
593
- const data = config3?.data ? await config3.data(req) : null;
594
- const originalResult = await componentFnDev({ req, res, data, config: config3, routePath: urlPath });
595
- const output = config3?.wrapper ? await config3.wrapper({ req, children: originalResult, config: config3, data, routePath: urlPath }) : originalResult;
721
+ const devConfig = await resolveConfig(configFilePaths, devImport);
722
+ const html2 = await renderJSX(devImport.default, devConfig, req, res, urlPath);
596
723
  context.clear();
597
- res.send(doctypeHTML + output);
724
+ res.send(html2);
598
725
  } else {
599
- await componentFnDev(req, res);
726
+ await devImport.default(req, res);
600
727
  }
601
728
  } catch (err) {
602
729
  console.error(err);
603
- res.status(err?.status || 500).send(err?.message || "Error");
730
+ const status = err?.status || 500;
731
+ const message = isDev || err?.status ? err?.message || "Error" : "Internal Server Error";
732
+ res.status(status).send(message);
604
733
  }
605
734
  }
606
- return {
607
- formattedRouteItem,
608
- foundMethod,
609
- route,
610
- config: config2,
611
- routeFn
735
+ const spread = isSpread ? await resolveSpreadRoute(templateRoute, config2, route.originalRoute) : null;
736
+ return { formattedRouteItem, foundMethod, route, config: config2, routeFn, spread };
737
+ }
738
+ function buildRouteRegister(router, method, config2, routeFn) {
739
+ return (routePath, preHandlers = []) => {
740
+ const handlers = [...preHandlers];
741
+ if (config2?.routeMiddleware) handlers.push(config2.routeMiddleware);
742
+ handlers.push(routeFn);
743
+ router[method](routePath, ...handlers);
612
744
  };
613
745
  }
614
746
  async function createRouter() {
@@ -619,46 +751,50 @@ async function createRouter() {
619
751
  await triggerXPineOnLoad();
620
752
  for (const route of routeMap) {
621
753
  try {
622
- const { formattedRouteItem, foundMethod, config: config2, routeFn } = await createRouteFunction(route, configFiles);
623
- if (config2?.routeMiddleware) {
624
- router[foundMethod || "get"](formattedRouteItem, config2.routeMiddleware, routeFn);
625
- } else {
626
- router[foundMethod || "get"](formattedRouteItem, routeFn);
627
- }
628
- routeResults.push({
629
- formattedRouteItem,
630
- foundMethod,
631
- route,
632
- routeFn
633
- });
754
+ const { formattedRouteItem, foundMethod, config: routeConfig, routeFn, spread } = await createRouteFunction(route, configFiles);
755
+ const register = buildRouteRegister(router, foundMethod || "get", routeConfig, routeFn);
756
+ if (spread) registerSpreadRoutes(register, spread);
757
+ else register(formattedRouteItem);
758
+ routeResults.push({ formattedRouteItem, foundMethod, route, routeFn });
634
759
  } catch (err) {
635
760
  console.error(err);
636
761
  }
637
762
  }
638
- return {
639
- router,
640
- routeResults
641
- };
763
+ return { router, routeResults };
642
764
  }
643
765
  async function verifyUserMiddleware(req, _res, next) {
644
- const { usertoken } = req.cookies;
766
+ const usertoken = req.cookies?.usertoken;
645
767
  if (!usertoken) {
646
768
  req.user = null;
769
+ next();
770
+ return;
647
771
  }
648
772
  try {
649
773
  const { user } = await verifyUser(usertoken);
650
774
  req.user = user;
651
- } catch (err) {
775
+ } catch {
652
776
  req.user = null;
653
777
  }
654
778
  next();
655
779
  }
656
780
  async function verifyAuthenticatedBundleMiddleware(req, res, next) {
657
- if (!req?.originalUrl?.startsWith("/scripts/")) {
781
+ let decodedPath;
782
+ try {
783
+ decodedPath = decodeURIComponent(req?.path || "");
784
+ } catch {
785
+ next();
786
+ return;
787
+ }
788
+ if (!decodedPath.startsWith("/scripts/")) {
789
+ next();
790
+ return;
791
+ }
792
+ const fileName = decodedPath.split("/").pop() || "";
793
+ if (!fileName.endsWith(".js")) {
658
794
  next();
659
795
  return;
660
796
  }
661
- const bundle = req?.originalUrl?.split("/")?.pop()?.split(".js")?.shift();
797
+ const bundle = fileName.slice(0, -".js".length);
662
798
  const foundBundle = config?.bundles?.find((bundleItem) => bundleItem?.id === bundle);
663
799
  if (!foundBundle) {
664
800
  next();
@@ -677,6 +813,9 @@ async function createXPineRouter(app, beforeErrorRoute) {
677
813
  app.use(verifyAuthenticatedBundleMiddleware);
678
814
  app.use(express.static(config.distPublicDir));
679
815
  app.use(requestIP.mw());
816
+ if (config?.csrf) {
817
+ app.use(createCsrfMiddleware(typeof config.csrf === "object" ? config.csrf : void 0));
818
+ }
680
819
  const { router, routeResults } = await createRouter();
681
820
  app.use(function replaceableRouter(req, res, next) {
682
821
  router(req, res, next);
@@ -696,11 +835,13 @@ function routeHasStaticPath(route, params) {
696
835
  const paramEntries = Object.entries(params);
697
836
  let routeToStaticPath = route;
698
837
  for (const [key, value] of paramEntries) {
699
- routeToStaticPath = routeToStaticPath.replace(`:${key}`, value);
700
- if (key === "0") routeToStaticPath = routeToStaticPath.replace(/\/\*/g, `/${value}`);
838
+ routeToStaticPath = routeToStaticPath.replace(`:${key}`, () => value);
839
+ if (key === "0") routeToStaticPath = routeToStaticPath.replace(/\/\*/g, () => `/${value}`);
701
840
  }
702
841
  routeToStaticPath += "/index.html";
703
- const outputPath = path5.join(config.distPagesDir, routeToStaticPath);
842
+ const pagesDir = path5.resolve(config.distPagesDir);
843
+ const outputPath = path5.resolve(pagesDir, "." + path5.posix.normalize("/" + routeToStaticPath));
844
+ if (outputPath !== pagesDir && !outputPath.startsWith(pagesDir + path5.sep)) return false;
704
845
  if (fs5.existsSync(outputPath)) return outputPath;
705
846
  return false;
706
847
  }
@@ -1118,7 +1259,7 @@ async function buildStaticFiles(config2, component, componentImport, builtCompon
1118
1259
  function determineOutputPathForStaticPath(builtComponentPath, componentDynamicPaths) {
1119
1260
  if (componentDynamicPaths?.length) {
1120
1261
  return componentDynamicPaths.reduce((total, current) => {
1121
- return total.replace(`/[${current}]`, "");
1262
+ return total.replace(`/[...${current}]`, "").replace(`/[${current}]`, "");
1122
1263
  }, path6.dirname(builtComponentPath));
1123
1264
  } else {
1124
1265
  if (builtComponentPath?.match(regex_default.endsWithIndex)) {
@@ -1134,7 +1275,8 @@ function getComponentDynamicPaths(componentPath) {
1134
1275
  if (!matches?.length) return null;
1135
1276
  const output = [];
1136
1277
  for (const match of matches) {
1137
- output.push(match[2]);
1278
+ if (!match[2]) continue;
1279
+ output.push(match[2].replace(/^\.\.\./, ""));
1138
1280
  }
1139
1281
  return output;
1140
1282
  }
@@ -1316,9 +1458,9 @@ async function runDevServer() {
1316
1458
  });
1317
1459
  }
1318
1460
  function asyncServerClose(server) {
1319
- return new Promise((resolve, reject) => {
1320
- server.close();
1321
- resolve(true);
1461
+ return new Promise((resolve) => {
1462
+ server.close(() => resolve(true));
1463
+ server.closeAllConnections?.();
1322
1464
  });
1323
1465
  }
1324
1466
  function createRebuildEmitter(delay = 500) {
@@ -1326,39 +1468,51 @@ function createRebuildEmitter(delay = 500) {
1326
1468
  }
1327
1469
  ;
1328
1470
  const rebuildEmitter = new RebuildEmitter();
1329
- let start = 0;
1330
- let hasEmitted = false;
1471
+ let timeout = null;
1331
1472
  rebuildEmitter.on("rebuild-server", () => {
1332
- hasEmitted = false;
1333
- trigger();
1473
+ if (timeout) clearTimeout(timeout);
1474
+ timeout = setTimeout(() => {
1475
+ timeout = null;
1476
+ rebuildEmitter.emit("done");
1477
+ }, delay);
1334
1478
  });
1335
- function trigger() {
1336
- start = Date.now();
1337
- const interval = setInterval(() => {
1338
- if (hasEmitted) return;
1339
- const now = Date.now();
1340
- if (now - start >= delay) {
1341
- rebuildEmitter.emit("done");
1342
- clearInterval(interval);
1343
- hasEmitted = true;
1344
- }
1345
- }, 100);
1346
- }
1347
1479
  return rebuildEmitter;
1348
1480
  }
1349
1481
 
1350
1482
  // src/util/html.ts
1483
+ var RawHtml = class {
1484
+ constructor(value) {
1485
+ this.value = value;
1486
+ }
1487
+ toString() {
1488
+ return this.value;
1489
+ }
1490
+ };
1491
+ var RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set(["script", "style"]);
1492
+ function escapeHtml(value) {
1493
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1494
+ }
1495
+ function raw(value) {
1496
+ return new RawHtml(value);
1497
+ }
1498
+ function renderChild(child, rawText) {
1499
+ if (Array.isArray(child)) return child.map((item) => renderChild(item, rawText)).join("");
1500
+ if (child instanceof RawHtml) return child.value;
1501
+ if (!child) return "";
1502
+ return rawText ? String(child) : escapeHtml(child);
1503
+ }
1351
1504
  var html = class {
1505
+ static raw = raw;
1352
1506
  static attributeObjectToString(props) {
1353
1507
  if (!props) return "";
1354
1508
  return Object.entries(props).filter(([, value]) => value !== null && value !== void 0).map(([key, value], index) => {
1355
1509
  const start = index === 0 ? " " : "";
1356
- return `${start}${key}="${value}"`;
1510
+ return `${start}${key}="${escapeHtml(value)}"`;
1357
1511
  }).join(" ");
1358
1512
  }
1359
1513
  static async fragment(props) {
1360
1514
  const childrenResult = await Promise.all(props.children.flat());
1361
- return childrenResult.filter(Boolean).join("");
1515
+ return new RawHtml(childrenResult.map((child) => renderChild(child, false)).join(""));
1362
1516
  }
1363
1517
  static async createElement(type, props, ...children) {
1364
1518
  const childrenResult = await Promise.all(children.flat());
@@ -1366,7 +1520,9 @@ var html = class {
1366
1520
  const result = await type({ ...props, children: childrenResult });
1367
1521
  return result;
1368
1522
  }
1369
- return `<${type}${this.attributeObjectToString(props)}>${childrenResult.filter(Boolean).join("")}</${type}>`;
1523
+ const rawText = RAW_TEXT_ELEMENTS.has(type);
1524
+ const inner = childrenResult.map((child) => renderChild(child, rawText)).join("");
1525
+ return new RawHtml(`<${type}${this.attributeObjectToString(props)}>${inner}</${type}>`);
1370
1526
  }
1371
1527
  };
1372
1528
  function JSXRuntime() {
@@ -1379,6 +1535,7 @@ function getCurrentBreakpoint() {
1379
1535
  }
1380
1536
  export {
1381
1537
  JSXRuntime,
1538
+ RawHtml,
1382
1539
  addToContextArray,
1383
1540
  buildApp,
1384
1541
  buildFilesWithConfigs,
@@ -1389,9 +1546,11 @@ export {
1389
1546
  config,
1390
1547
  context,
1391
1548
  createContext,
1549
+ createCsrfMiddleware,
1392
1550
  createRouter,
1393
1551
  createXPineRouter,
1394
1552
  determineOutputPathForStaticPath,
1553
+ escapeHtml,
1395
1554
  filePathToURLPath,
1396
1555
  fromRoot,
1397
1556
  getComponentDynamicPaths,
@@ -1399,9 +1558,13 @@ export {
1399
1558
  getTokenFromRequest,
1400
1559
  html,
1401
1560
  logSize,
1561
+ raw,
1402
1562
  routeHasStaticPath,
1403
1563
  runDevServer,
1404
1564
  setupEnv,
1405
1565
  signUser,
1566
+ spreadConcretePath,
1567
+ spreadSlugName,
1568
+ spreadWildcardPath,
1406
1569
  verifyUser
1407
1570
  };
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAGzC,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,oBAiB7D;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAQ5D;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,UAMrD"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAgBzC,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,oBAgB7D;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAO5D;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,UAKrD"}
@@ -0,0 +1,16 @@
1
+ import { NextFunction, Request, Response } from 'express';
2
+ export type CsrfOptions = {
3
+ cookieName?: string;
4
+ headerName?: string;
5
+ fieldName?: string;
6
+ secret?: string;
7
+ cookie?: {
8
+ sameSite?: boolean | 'lax' | 'strict' | 'none';
9
+ secure?: boolean;
10
+ path?: string;
11
+ maxAge?: number;
12
+ };
13
+ ignorePaths?: string[];
14
+ };
15
+ export declare function createCsrfMiddleware(options?: CsrfOptions): (req: Request, res: Response, next: NextFunction) => void;
16
+ //# sourceMappingURL=csrf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.d.ts","sourceRoot":"","sources":["../../src/csrf.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAK1D,MAAM,MAAM,WAAW,GAAG;IAExB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB,MAAM,CAAC,EAAE;QACP,QAAQ,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;QAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;IAEF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAoDF,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE,WAAW,SAepB,OAAO,OAAO,QAAQ,QAAQ,YAAY,UAqB/E"}
@@ -1,4 +1,9 @@
1
1
  import { Express } from 'express';
2
+ export declare function spreadConcretePath(templateRoute: string, entry: {
3
+ [key: string]: string;
4
+ }): string;
5
+ export declare function spreadWildcardPath(templateRoute: string): string;
6
+ export declare function spreadSlugName(templateRoute: string): string | null;
2
7
  export declare function createRouter(): Promise<{
3
8
  router: import("express-serve-static-core").Router;
4
9
  routeResults: any[];
@@ -1 +1 @@
1
- {"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/express.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAgB,OAAO,EAAqB,MAAM,SAAS,CAAC;AAqJ5E,wBAAsB,YAAY;;;GAkCjC;AAsCD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,GAAG,EAAE,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,iBA0B1F;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,KAAK,CAanG;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,OAAc,UAQzE"}
1
+ {"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/express.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAgB,OAAO,EAA6C,MAAM,SAAS,CAAC;AA8FpG,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,GAAG,MAAM,CAKlG;AAGD,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAIhE;AAGD,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGnE;AAqLD,wBAAsB,YAAY;;;GAuBjC;AAuDD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,GAAG,EAAE,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,iBAgC1F;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,KAAK,CAoBnG;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,OAAc,UAQzE"}
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../../src/scripts/build.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,UAAU,EAA2B,aAAa,EAAE,MAAM,aAAa,CAAC;AAajF,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAA;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,iBAgDhD;AA4JD,wBAAsB,yBAAyB,kBAiC9C;AAED,wBAAsB,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,eAAe,WAAkB,iBAa9F;AAED,wBAAsB,qBAAqB,CAAC,aAAa,EAAE,aAAa,EAAE,iBAezE;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,iBA2FpI;AAED,wBAAgB,gCAAgC,CAAC,kBAAkB,EAAE,MAAM,EAAE,qBAAqB,CAAC,EAAE,MAAM,EAAE,UAe5G;AAED,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CAQxE;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ,CAAA;AAGD,wBAAsB,eAAe,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,KAAK,CAAC,EAAE,OAAO,iBAoDpF;AAED,wBAAsB,YAAY,kBA4CjC"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../../src/scripts/build.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,UAAU,EAA2B,aAAa,EAAE,MAAM,aAAa,CAAC;AAajF,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAA;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,iBAgDhD;AA4JD,wBAAsB,yBAAyB,kBAiC9C;AAED,wBAAsB,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,eAAe,WAAkB,iBAa9F;AAED,wBAAsB,qBAAqB,CAAC,aAAa,EAAE,aAAa,EAAE,iBAezE;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,iBA2FpI;AAED,wBAAgB,gCAAgC,CAAC,kBAAkB,EAAE,MAAM,EAAE,qBAAqB,CAAC,EAAE,MAAM,EAAE,UAgB5G;AAED,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CAUxE;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ,CAAA;AAGD,wBAAsB,eAAe,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,KAAK,CAAC,EAAE,OAAO,iBAoDpF;AAED,wBAAsB,YAAY,kBA4CjC"}
@@ -321,6 +321,9 @@ var regex_default = {
321
321
  configFile: /\+config\.[tj]sx?/g,
322
322
  dynamicRoutes: /(\[)(.*?)(\])/g,
323
323
  isDynamicRoute: /\[(.*)\]/g,
324
+ // Multi-segment (slash-containing) dynamic route, e.g. [...slug]
325
+ spreadRoute: /\[\.\.\.(.*?)\]/g,
326
+ isSpreadRoute: /\[\.\.\..*?\]/,
324
327
  endsWithTSX: /\.tsx$/,
325
328
  endsWithJSX: /\.jsx$/,
326
329
  endsWithJs: /\.js$/,
@@ -891,7 +894,7 @@ async function buildStaticFiles(config2, component, componentImport, builtCompon
891
894
  function determineOutputPathForStaticPath(builtComponentPath, componentDynamicPaths) {
892
895
  if (componentDynamicPaths?.length) {
893
896
  return componentDynamicPaths.reduce((total, current) => {
894
- return total.replace(`/[${current}]`, "");
897
+ return total.replace(`/[...${current}]`, "").replace(`/[${current}]`, "");
895
898
  }, path5.dirname(builtComponentPath));
896
899
  } else {
897
900
  if (builtComponentPath?.match(regex_default.endsWithIndex)) {
@@ -907,7 +910,8 @@ function getComponentDynamicPaths(componentPath) {
907
910
  if (!matches?.length) return null;
908
911
  const output = [];
909
912
  for (const match of matches) {
910
- output.push(match[2]);
913
+ if (!match[2]) continue;
914
+ output.push(match[2].replace(/^\.\.\./, ""));
911
915
  }
912
916
  return output;
913
917
  }
@@ -328,6 +328,9 @@ var regex_default = {
328
328
  configFile: /\+config\.[tj]sx?/g,
329
329
  dynamicRoutes: /(\[)(.*?)(\])/g,
330
330
  isDynamicRoute: /\[(.*)\]/g,
331
+ // Multi-segment (slash-containing) dynamic route, e.g. [...slug]
332
+ spreadRoute: /\[\.\.\.(.*?)\]/g,
333
+ isSpreadRoute: /\[\.\.\..*?\]/,
331
334
  endsWithTSX: /\.tsx$/,
332
335
  endsWithJSX: /\.jsx$/,
333
336
  endsWithJs: /\.js$/,
@@ -898,7 +901,7 @@ async function buildStaticFiles(config2, component, componentImport, builtCompon
898
901
  function determineOutputPathForStaticPath(builtComponentPath, componentDynamicPaths) {
899
902
  if (componentDynamicPaths?.length) {
900
903
  return componentDynamicPaths.reduce((total, current) => {
901
- return total.replace(`/[${current}]`, "");
904
+ return total.replace(`/[...${current}]`, "").replace(`/[${current}]`, "");
902
905
  }, path5.dirname(builtComponentPath));
903
906
  } else {
904
907
  if (builtComponentPath?.match(regex_default.endsWithIndex)) {
@@ -914,7 +917,8 @@ function getComponentDynamicPaths(componentPath) {
914
917
  if (!matches?.length) return null;
915
918
  const output = [];
916
919
  for (const match of matches) {
917
- output.push(match[2]);
920
+ if (!match[2]) continue;
921
+ output.push(match[2].replace(/^\.\.\./, ""));
918
922
  }
919
923
  return output;
920
924
  }
@@ -1096,9 +1100,9 @@ async function runDevServer() {
1096
1100
  });
1097
1101
  }
1098
1102
  function asyncServerClose(server) {
1099
- return new Promise((resolve, reject) => {
1100
- server.close();
1101
- resolve(true);
1103
+ return new Promise((resolve) => {
1104
+ server.close(() => resolve(true));
1105
+ server.closeAllConnections?.();
1102
1106
  });
1103
1107
  }
1104
1108
  function createRebuildEmitter(delay = 500) {
@@ -1106,24 +1110,14 @@ function createRebuildEmitter(delay = 500) {
1106
1110
  }
1107
1111
  ;
1108
1112
  const rebuildEmitter = new RebuildEmitter();
1109
- let start = 0;
1110
- let hasEmitted = false;
1113
+ let timeout = null;
1111
1114
  rebuildEmitter.on("rebuild-server", () => {
1112
- hasEmitted = false;
1113
- trigger();
1115
+ if (timeout) clearTimeout(timeout);
1116
+ timeout = setTimeout(() => {
1117
+ timeout = null;
1118
+ rebuildEmitter.emit("done");
1119
+ }, delay);
1114
1120
  });
1115
- function trigger() {
1116
- start = Date.now();
1117
- const interval = setInterval(() => {
1118
- if (hasEmitted) return;
1119
- const now = Date.now();
1120
- if (now - start >= delay) {
1121
- rebuildEmitter.emit("done");
1122
- clearInterval(interval);
1123
- hasEmitted = true;
1124
- }
1125
- }, 100);
1126
- }
1127
1121
  return rebuildEmitter;
1128
1122
  }
1129
1123
 
@@ -1,6 +1,14 @@
1
+ export declare class RawHtml {
2
+ value: string;
3
+ constructor(value: string);
4
+ toString(): string;
5
+ }
6
+ export declare function escapeHtml(value: unknown): string;
7
+ export declare function raw(value: string): RawHtml;
1
8
  export declare class html {
9
+ static raw: typeof raw;
2
10
  static attributeObjectToString(props: any): string;
3
- static fragment(props: any): Promise<string>;
11
+ static fragment(props: any): Promise<RawHtml>;
4
12
  static createElement(type: any, props: any, ...children: any[]): Promise<any>;
5
13
  }
6
14
  export declare function JSXRuntime(): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../../src/util/html.ts"],"names":[],"mappings":"AAAA,qBAAa,IAAI;IAEf,MAAM,CAAC,uBAAuB,CAAC,KAAK,KAAA;WAWvB,QAAQ,CAAC,KAAK,KAAA;WAKd,aAAa,CAAC,IAAI,KAAA,EAAE,KAAK,KAAA,EAAE,GAAG,QAAQ,OAAA;CASpD;AAED,wBAAgB,UAAU,YAEzB"}
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../../src/util/html.ts"],"names":[],"mappings":"AAGA,qBAAa,OAAO;IACC,KAAK,EAAE,MAAM;gBAAb,KAAK,EAAE,MAAM;IAChC,QAAQ;CACT;AAKD,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAOjD;AAID,wBAAgB,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE1C;AAUD,qBAAa,IAAI;IACf,MAAM,CAAC,GAAG,aAAO;IAEjB,MAAM,CAAC,uBAAuB,CAAC,KAAK,KAAA;WAYvB,QAAQ,CAAC,KAAK,KAAA;WAKd,aAAa,CAAC,IAAI,KAAA,EAAE,KAAK,KAAA,EAAE,GAAG,QAAQ,OAAA;CAWpD;AAED,wBAAgB,UAAU,YAEzB"}
@@ -4,6 +4,8 @@ declare const _default: {
4
4
  configFile: RegExp;
5
5
  dynamicRoutes: RegExp;
6
6
  isDynamicRoute: RegExp;
7
+ spreadRoute: RegExp;
8
+ isSpreadRoute: RegExp;
7
9
  endsWithTSX: RegExp;
8
10
  endsWithJSX: RegExp;
9
11
  endsWithJs: RegExp;
@@ -1 +1 @@
1
- {"version":3,"file":"regex.d.ts","sourceRoot":"","sources":["../../../src/util/regex.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAAA,wBAiBE"}
1
+ {"version":3,"file":"regex.d.ts","sourceRoot":"","sources":["../../../src/util/regex.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,wBAoBE"}
package/dist/types.d.ts CHANGED
@@ -21,10 +21,13 @@ export type WrapperProps = {
21
21
  export type ConfigFile = {
22
22
  staticPaths?: boolean | (() => Promise<{
23
23
  [key: string]: string;
24
- }[]>);
24
+ }[]> | {
25
+ [key: string]: string;
26
+ }[]);
25
27
  wrapper?: (props: WrapperProps) => Promise<any>;
26
28
  data?: (req: ServerRequest) => Promise<any>;
27
29
  routeMiddleware?: (req: ServerRequest, res: Response, next: NextFunction) => void;
30
+ isValid?: (slug: string, req: ServerRequest) => boolean | Promise<boolean>;
28
31
  };
29
32
  export type PageProps = {
30
33
  req: ServerRequest;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,MAAM,WAAW,GAAG;IACxB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG;IACpC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,aAAa,CAAC;IACnB,QAAQ,EAAE,GAAG,CAAC;IACd,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,GAAG,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC,CAAC;IACrE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAChD,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;CACnF,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,GAAG,EAAE,aAAa,CAAC;IACnB,GAAG,EAAE,QAAQ,CAAC;IACd,IAAI,EAAE,GAAG,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,EAAE,CAAC,UAAU,CAAC;CACvB,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,MAAM,WAAW,GAAG;IACxB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG;IACpC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,aAAa,CAAC;IACnB,QAAQ,EAAE,GAAG,CAAC;IACd,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,GAAG,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,EAAE,CAAC,GAAG;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACnG,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAChD,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IAIlF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5E,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,GAAG,EAAE,aAAa,CAAC;IACnB,GAAG,EAAE,QAAQ,CAAC;IACd,IAAI,EAAE,GAAG,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,EAAE,CAAC,UAAU,CAAC;CACvB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xpine",
3
- "version": "0.0.56",
3
+ "version": "0.0.59",
4
4
  "main": "dist/index.js",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-secrets-manager": "^3.758.0",
package/types.ts CHANGED
@@ -24,10 +24,14 @@ export type WrapperProps = {
24
24
  }
25
25
 
26
26
  export type ConfigFile = {
27
- staticPaths?: boolean | (() => Promise<{ [key: string]: string }[]>);
27
+ staticPaths?: boolean | (() => Promise<{ [key: string]: string }[]> | { [key: string]: string }[]);
28
28
  wrapper?: (props: WrapperProps) => Promise<any>;
29
29
  data?: (req: ServerRequest) => Promise<any>;
30
30
  routeMiddleware?: (req: ServerRequest, res: Response, next: NextFunction) => void;
31
+ // For multi-segment dynamic routes ([...slug]): validate a slug that was not
32
+ // generated at build time so it can be resolved safely at request time.
33
+ // Return false (the default for unknown slugs) to fall through to the 404 handler.
34
+ isValid?: (slug: string, req: ServerRequest) => boolean | Promise<boolean>;
31
35
  }
32
36
 
33
37
  export type PageProps = {