@server/next 0.28.7 → 0.28.9

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/index.d.ts CHANGED
@@ -1,3 +1,18 @@
1
+ type LimitOptions = {
2
+ maxSize?: number | string;
3
+ minSize?: number | string;
4
+ fileType?: string[];
5
+ };
6
+ declare class UploadPipeline {
7
+ private _bucket;
8
+ private _limits;
9
+ constructor(bucket?: Bucket | null);
10
+ limit(options: LimitOptions): this;
11
+ store(bucket: Bucket): this;
12
+ processFile(originalName: string, data: Buffer, contentType: string): Promise<UploadedFile>;
13
+ }
14
+ declare function upload(bucket?: Bucket | null): UploadPipeline;
15
+
1
16
  type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "socket";
2
17
  type ServerConfig<Session = {}, User = {}> = {
3
18
  Session?: Session;
@@ -28,6 +43,13 @@ type Bucket = {
28
43
  write: (path: string, data: string | Buffer) => Promise<void | string>;
29
44
  delete: (path: string) => Promise<boolean>;
30
45
  };
46
+ type UploadedFile = {
47
+ name: string;
48
+ id: string;
49
+ path: string;
50
+ type: string;
51
+ size: number;
52
+ };
31
53
  type CorsSettings = {
32
54
  origin: string | boolean;
33
55
  methods: string;
@@ -88,7 +110,7 @@ type Options = {
88
110
  secret?: string;
89
111
  views?: string | Bucket;
90
112
  public?: string | Bucket;
91
- uploads?: string | Bucket;
113
+ uploads?: string | Bucket | UploadPipeline;
92
114
  store?: KVStore;
93
115
  cookies?: KVStore;
94
116
  session?: KVStore | {
@@ -104,7 +126,7 @@ type Settings = {
104
126
  secret: string;
105
127
  views?: Bucket;
106
128
  public?: Bucket;
107
- uploads?: Bucket;
129
+ uploads?: Bucket | UploadPipeline;
108
130
  store?: KVStore;
109
131
  cookies?: KVStore;
110
132
  session?: {
@@ -402,4 +424,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
402
424
  }
403
425
  declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
404
426
 
405
- export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type Bucket, type BunEnv, type Context, type Cookie, type CorsSettings, type EventCallback, type ExtractPathParams, type InferParamType, type InlineReply, type KVStore, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type Provider, type RouteOptions, type RouterMethod, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type Strategy, type Time, cookies, server as default, download, file, headers, json, redirect, router, send, status, type };
427
+ export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type Bucket, type BunEnv, type Context, type Cookie, type CorsSettings, type EventCallback, type ExtractPathParams, type InferParamType, type InlineReply, type KVStore, type LimitOptions, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type Provider, type RouteOptions, type RouterMethod, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type Strategy, type Time, UploadPipeline, type UploadedFile, cookies, server as default, download, file, headers, json, redirect, router, send, status, type, upload };
package/index.js CHANGED
@@ -197,7 +197,7 @@ var Reply = class {
197
197
  }
198
198
  download(name) {
199
199
  const ext = name?.split(".").pop();
200
- if (type && ext && !this.res.headers.get("content-type")) this.type(ext);
200
+ if (ext && !this.res.headers.get("content-type")) this.type(ext);
201
201
  const filename = name ? `; filename="${encodeURIComponent(name)}"` : "";
202
202
  return this.headers("content-disposition", `attachment${filename}`);
203
203
  }
@@ -542,59 +542,83 @@ function createId(source, size = 16) {
542
542
  return randomId(size);
543
543
  }
544
544
 
545
- // src/helpers/color.ts
546
- var map = {
547
- reset: 0,
548
- bright: 1,
549
- dim: 2,
550
- under: 4,
551
- blink: 5,
552
- reverse: 7,
553
- black: 30,
554
- red: 31,
555
- green: 32,
556
- yellow: 33,
557
- blue: 34,
558
- magenta: 35,
559
- cyan: 36,
560
- white: 37,
561
- bgblack: 40,
562
- bgred: 41,
563
- bggreen: 42,
564
- bgyellow: 43,
565
- bgblue: 44,
566
- bgmagenta: 45,
567
- bgcyan: 46,
568
- bgwhite: 47
569
- };
570
- var replace = (k) => {
571
- if (process.env.NO_COLOR) return "";
572
- if (!(k in map)) throw new Error(`"{${k}}" is not a valid color`);
573
- return `\x1B[${map[k]}m`;
574
- };
575
- function color(str, ...vals) {
576
- if (typeof str === "string") {
577
- return str.replace(/\{(\w+)\}/g, (_m, k) => replace(k)).replace(/\{\/\w*\}/g, () => replace("reset"));
578
- }
579
- return color(str[0] + vals.map((v, i) => v + str[i + 1]).join(""));
545
+ // src/helpers/upload.ts
546
+ function parseBytes(value) {
547
+ if (typeof value === "number") return value;
548
+ const units = {
549
+ b: 1,
550
+ kb: 1024,
551
+ mb: 1024 ** 2,
552
+ gb: 1024 ** 3
553
+ };
554
+ const match = value.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/);
555
+ if (!match) throw new Error(`Invalid size: "${value}"`);
556
+ return parseFloat(match[1]) * (units[match[2]] ?? 1);
580
557
  }
581
-
582
- // src/helpers/debugInfo.ts
583
- var isDebug = process.argv.includes("--debug");
584
- function debugInfo(options, name, cb, icon = "") {
585
- if (!isDebug) return;
586
- if (!options[name]) {
587
- console.log(color`options:${String(name)}\t→ {dim}[not set]{/}`);
588
- return;
558
+ function getExt(filename) {
559
+ const i = filename.lastIndexOf(".");
560
+ if (i <= 0) return ".bin";
561
+ return filename.slice(i).toLowerCase();
562
+ }
563
+ async function saveFileToBucket(originalName, data, bucket, contentType) {
564
+ const ext = getExt(originalName);
565
+ const id = `${createId()}${ext}`;
566
+ const path2 = await bucket.write(id, data);
567
+ return { name: originalName, id, path: path2, type: contentType, size: data.length };
568
+ }
569
+ var UploadPipeline = class {
570
+ _bucket;
571
+ _limits = {};
572
+ constructor(bucket) {
573
+ this._bucket = bucket ?? null;
574
+ }
575
+ limit(options) {
576
+ this._limits = { ...this._limits, ...options };
577
+ return this;
589
578
  }
590
- console.log(
591
- color`options:${String(name)}\t→ ${icon ? `${icon} ` : ""}${cb(options[name])}`
592
- );
579
+ store(bucket) {
580
+ this._bucket = bucket;
581
+ return this;
582
+ }
583
+ async processFile(originalName, data, contentType) {
584
+ const { maxSize, minSize, fileType } = this._limits;
585
+ if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
586
+ throw new Error(
587
+ `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
588
+ );
589
+ }
590
+ if (minSize !== void 0 && data.length < parseBytes(minSize)) {
591
+ throw new Error(
592
+ `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
593
+ );
594
+ }
595
+ if (fileType && fileType.length > 0) {
596
+ const ext = getExt(originalName);
597
+ const mime = contentType.toLowerCase();
598
+ const allowed = fileType.some(
599
+ (t) => t.toLowerCase() === mime || t.toLowerCase() === ext
600
+ );
601
+ if (!allowed) {
602
+ throw new Error(
603
+ `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
604
+ );
605
+ }
606
+ }
607
+ if (!this._bucket) {
608
+ throw new Error(
609
+ `No destination configured \u2014 pass a bucket to upload() or call .store()`
610
+ );
611
+ }
612
+ return saveFileToBucket(originalName, data, this._bucket, contentType);
613
+ }
614
+ };
615
+ function upload(bucket) {
616
+ return new UploadPipeline(bucket);
593
617
  }
594
618
 
595
619
  // src/helpers/config.ts
596
- var env2 = globalThis.env;
597
620
  function config(options = {}) {
621
+ const env2 = globalThis.env;
598
622
  const settings = {
599
623
  port: options.port || env2.PORT || 3e3,
600
624
  secret: options.secret || env2.SECRET || `unsafe-${createId()}`
@@ -633,27 +657,16 @@ function config(options = {}) {
633
657
  settings.cors = cors2;
634
658
  }
635
659
  settings.views = options.views ? bucket_default(options.views) : null;
636
- debugInfo(options, "views", (views) => views?.location || "true", "\u{1F4C2}");
637
660
  settings.public = options.public ? bucket_default(options.public) : null;
638
- debugInfo(options, "public", (pub) => pub?.location || "true", "\u{1F4C2}");
639
- settings.uploads = options.uploads ? bucket_default(options.uploads) : null;
640
- debugInfo(options, "uploads", (ups) => ups?.location || "true", "\u{1F4C2}");
661
+ settings.uploads = options.uploads instanceof UploadPipeline ? options.uploads : options.uploads ? bucket_default(options.uploads) : null;
641
662
  settings.store = options.store ?? null;
642
- debugInfo(options, "store", (store) => store?.name || "working", "\u{1F4E6}");
643
663
  settings.cookies = options.cookies ?? null;
644
- debugInfo(options, "cookies", (cookies2) => cookies2?.name || "working", "\u{1F36A}");
645
664
  if (options.session) {
646
665
  settings.session = "store" in options.session ? options.session : { store: options.session };
647
666
  }
648
667
  if (options.store && !options.session) {
649
668
  settings.session = { store: options.store.prefix("session:") };
650
669
  }
651
- debugInfo(
652
- options,
653
- "session",
654
- (session2) => session2?.store?.name || "working",
655
- "\u{1F510}"
656
- );
657
670
  if (options.auth || env2.AUTH) {
658
671
  settings.auth = parseAuthOptions(options.auth || env2.AUTH || null, options);
659
672
  }
@@ -795,7 +808,7 @@ function getMachine() {
795
808
 
796
809
  // src/parseResponse.ts
797
810
  async function parseResponse(out, ctx) {
798
- if (!out && typeof out !== "string") return;
811
+ if (!out && typeof out !== "string") return null;
799
812
  if (typeof out === "function") {
800
813
  out = await out(ctx);
801
814
  }
@@ -1071,12 +1084,6 @@ function getMatching(string, regex) {
1071
1084
  const matches = string.match(regex);
1072
1085
  return matches?.[1] ?? "";
1073
1086
  }
1074
- var saveFile = async (name, value, bucket) => {
1075
- const ext = name.split(".").pop();
1076
- const id = `${createId()}.${ext}`;
1077
- await bucket.write(id, value);
1078
- return id;
1079
- };
1080
1087
  function splitBuffer(buffer, delimiter) {
1081
1088
  const result = [];
1082
1089
  let start = 0;
@@ -1090,6 +1097,7 @@ function splitBuffer(buffer, delimiter) {
1090
1097
  return result;
1091
1098
  }
1092
1099
  var BREAK_BUFFER = Buffer.from("\r\n\r\n");
1100
+ var END_BUFFER = Buffer.from("--\r\n");
1093
1101
  function isProbablyText(buffer) {
1094
1102
  for (let i = 0; i < Math.min(buffer.length, 512); i++) {
1095
1103
  const byte = buffer[i];
@@ -1100,7 +1108,7 @@ function isProbablyText(buffer) {
1100
1108
  }
1101
1109
  async function parseBody(raw, contentType, bucket) {
1102
1110
  const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType;
1103
- if (!raw) return {};
1111
+ if (!raw || raw.length === 0) return {};
1104
1112
  if (!contentTypeStr || /^text\//.test(contentTypeStr)) {
1105
1113
  return raw.toString("utf-8");
1106
1114
  }
@@ -1113,7 +1121,7 @@ async function parseBody(raw, contentType, bucket) {
1113
1121
  const boundaryBuffer = Buffer.from(`--${boundary}`);
1114
1122
  const parts = splitBuffer(raw, boundaryBuffer);
1115
1123
  for (const part of parts) {
1116
- if (part.length === 0 || part.equals(Buffer.from("--\r\n"))) continue;
1124
+ if (part.length === 0 || part.equals(END_BUFFER)) continue;
1117
1125
  const idx = part.indexOf(BREAK_BUFFER);
1118
1126
  if (idx === -1) continue;
1119
1127
  const headerStr = part.slice(0, idx).toString("utf-8");
@@ -1122,8 +1130,24 @@ async function parseBody(raw, contentType, bucket) {
1122
1130
  if (!name) continue;
1123
1131
  const filename = getMatching(headerStr, /filename="(.+?)"/).trim();
1124
1132
  if (filename) {
1125
- if (!bucket) throw new Error("Bucket is required to save files");
1126
- body[name] = await saveFile(filename, contentBuf, bucket);
1133
+ const partContentType = getMatching(headerStr, /Content-Type:\s*([^\r\n]+)/i).trim() || "application/octet-stream";
1134
+ if (!bucket) {
1135
+ continue;
1136
+ }
1137
+ if (bucket instanceof UploadPipeline) {
1138
+ body[name] = await bucket.processFile(
1139
+ filename,
1140
+ contentBuf,
1141
+ partContentType
1142
+ );
1143
+ } else {
1144
+ body[name] = await saveFileToBucket(
1145
+ filename,
1146
+ contentBuf,
1147
+ bucket,
1148
+ partContentType
1149
+ );
1150
+ }
1127
1151
  } else {
1128
1152
  const value = isProbablyText(contentBuf) ? contentBuf.toString("utf-8").trim() : contentBuf;
1129
1153
  if (body[name]) {
@@ -1749,9 +1773,9 @@ async function createWinter(req, app) {
1749
1773
  }
1750
1774
 
1751
1775
  // src/context/handlers.ts
1752
- var Winter = async (app, request, env3) => {
1753
- if (env3?.upgrade(request)) return;
1754
- Object.assign(globalThis.env, env3);
1776
+ var Winter = async (app, request, env2) => {
1777
+ if (env2?.upgrade(request)) return;
1778
+ Object.assign(globalThis.env, env2);
1755
1779
  const ctx = await createWinter(request, app);
1756
1780
  const res = await handleRequest(app.handlers, ctx);
1757
1781
  ctx.events.trigger("finish", { ...ctx, res, end: performance.now() });
@@ -1965,8 +1989,8 @@ var Server = class extends Router {
1965
1989
  node() {
1966
1990
  return Node(this);
1967
1991
  }
1968
- fetch(request, env3) {
1969
- return Winter(this, request, env3);
1992
+ fetch(request, env2) {
1993
+ return Winter(this, request, env2);
1970
1994
  }
1971
1995
  callback(request, context) {
1972
1996
  return Netlify(this, request, context);
@@ -1991,5 +2015,6 @@ export {
1991
2015
  router,
1992
2016
  send,
1993
2017
  status,
1994
- type
2018
+ type,
2019
+ upload
1995
2020
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.28.7",
3
+ "version": "0.28.9",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "https://github.com/franciscop/server-next.git",
@@ -47,6 +47,7 @@
47
47
  "@types/bun": "^1.3.0",
48
48
  "@types/jest": "^30.0.0",
49
49
  "@types/node": "^24.10.0",
50
+ "bun": "^1.3.13",
50
51
  "check-dts": "^0.8.2",
51
52
  "jest": "^29.7.0",
52
53
  "polystore": "^0.18.0",
package/readme.md CHANGED
@@ -1 +1 @@
1
- # Server JS
1
+ # Server JS [![test badge](https://github.com/franciscop/server-next/workflows/tests/badge.svg)](https://github.com/franciscop/server-next/actions)
@@ -99,7 +99,9 @@ const jsx = (tag, { children, ...props } = {}) => {
99
99
  const parts = Array.isArray(children) ? children : [children];
100
100
  const content = parts
101
101
  .filter(isValidChild)
102
- .map((c) => (typeof c === "string" ? c : typeof c === "number" ? String(c) : ""))
102
+ .map((c) =>
103
+ typeof c === "string" ? c : typeof c === "number" ? String(c) : "",
104
+ )
103
105
  .join("");
104
106
  children = raw(content);
105
107
  }
@@ -135,6 +137,16 @@ const jsx = (tag, { children, ...props } = {}) => {
135
137
 
136
138
  if (v === true) return key;
137
139
 
140
+ if (k === "style" && v && typeof v === "object") {
141
+ v = Object.entries(v)
142
+ .filter(([, val]) => val != null)
143
+ .map(([prop, val]) => {
144
+ const cssKey = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
145
+ return `${cssKey}:${val}`;
146
+ })
147
+ .join(";");
148
+ }
149
+
138
150
  const value =
139
151
  typeof v === "string" || typeof v === "number" ? encode(String(v)) : "";
140
152
 
@@ -142,9 +142,13 @@ describe("text encoding", () => {
142
142
 
143
143
  it("renders array children", () => {
144
144
  const items = ["x", "y", "z"];
145
- expect(<span>{items.map((i) => <b key={i}>{i}</b>)}</span>).toRender(
146
- "<span><b>x</b><b>y</b><b>z</b></span>",
147
- );
145
+ expect(
146
+ <span>
147
+ {items.map((i) => (
148
+ <b key={i}>{i}</b>
149
+ ))}
150
+ </span>,
151
+ ).toRender("<span><b>x</b><b>y</b><b>z</b></span>");
148
152
  });
149
153
 
150
154
  it("treats string returned from a function child as raw HTML", () => {
@@ -171,9 +175,7 @@ describe("attribute encoding", () => {
171
175
  });
172
176
 
173
177
  it("encodes ' in attribute values", () => {
174
- expect(<div title={"it's"}></div>).toRender(
175
- '<div title="it&#39;s"></div>',
176
- );
178
+ expect(<div title={"it's"}></div>).toRender('<div title="it&#39;s"></div>');
177
179
  });
178
180
 
179
181
  it("maps className to class", () => {
@@ -316,9 +318,9 @@ describe("style tag", () => {
316
318
  });
317
319
 
318
320
  it("preserves child combinator in selectors", () => {
319
- expect(
320
- <style>{":not(pre) > code { background: none; }"}</style>,
321
- ).toRender("<style>:not(pre)>code{background:none}</style>");
321
+ expect(<style>{":not(pre) > code { background: none; }"}</style>).toRender(
322
+ "<style>:not(pre)>code{background:none}</style>",
323
+ );
322
324
  });
323
325
 
324
326
  it("preserves sibling combinators in selectors", () => {
@@ -328,21 +330,21 @@ describe("style tag", () => {
328
330
  });
329
331
 
330
332
  it("removes spaces around braces, colons, and semicolons", () => {
331
- expect(
332
- <style>{"a { color : red ; font-size : 1em ; }"}</style>,
333
- ).toRender("<style>a{color:red;font-size:1em}</style>");
333
+ expect(<style>{"a { color : red ; font-size : 1em ; }"}</style>).toRender(
334
+ "<style>a{color:red;font-size:1em}</style>",
335
+ );
334
336
  });
335
337
 
336
338
  it("removes trailing semicolon before closing brace", () => {
337
- expect(
338
- <style>{"p { margin: 0; padding: 0; }"}</style>,
339
- ).toRender("<style>p{margin:0;padding:0}</style>");
339
+ expect(<style>{"p { margin: 0; padding: 0; }"}</style>).toRender(
340
+ "<style>p{margin:0;padding:0}</style>",
341
+ );
340
342
  });
341
343
 
342
344
  it("does not break </style> injection", () => {
343
- expect(
344
- <style>{"a { content: '</style>'; }"}</style>,
345
- ).toRender("<style>a{content:'<\\/style>'}</style>");
345
+ expect(<style>{"a { content: '</style>'; }"}</style>).toRender(
346
+ "<style>a{content:'<\\/style>'}</style>",
347
+ );
346
348
  });
347
349
  });
348
350
 
@@ -368,9 +370,9 @@ describe("dangerouslySetInnerHTML", () => {
368
370
  });
369
371
 
370
372
  it("does not render dangerouslySetInnerHTML as an attribute", () => {
371
- expect(
372
- <div dangerouslySetInnerHTML={{ __html: "hi" }}></div>,
373
- ).toRender("<div>hi</div>");
373
+ expect(<div dangerouslySetInnerHTML={{ __html: "hi" }}></div>).toRender(
374
+ "<div>hi</div>",
375
+ );
374
376
  });
375
377
  });
376
378
 
@@ -411,18 +413,14 @@ describe("function components", () => {
411
413
 
412
414
  it("encodes text returned by a component", () => {
413
415
  const Unsafe = () => "<script>alert(1)</script>";
414
- expect(<Unsafe />).toRender(
415
- "&lt;script&gt;alert(1)&lt;/script&gt;",
416
- );
416
+ expect(<Unsafe />).toRender("&lt;script&gt;alert(1)&lt;/script&gt;");
417
417
  });
418
418
  });
419
419
 
420
420
  describe("forwardRef-like components", () => {
421
421
  it("renders an object with a render function", () => {
422
422
  const Button = { render: ({ children }) => <button>{children}</button> };
423
- expect(<Button>click me</Button>).toRender(
424
- "<button>click me</button>",
425
- );
423
+ expect(<Button>click me</Button>).toRender("<button>click me</button>");
426
424
  });
427
425
 
428
426
  it("passes props to render function", () => {
@@ -487,3 +485,55 @@ describe("React element interop", () => {
487
485
  expect(<Deep />).toRender("<div><ul><li>one</li><li>two</li></ul></div>");
488
486
  });
489
487
  });
488
+ describe("styles", () => {
489
+ it("stringifies style object into CSS", () => {
490
+ expect(
491
+ <div
492
+ style={{
493
+ display: "inline-block",
494
+ padding: "2px 8px",
495
+ borderRadius: "4px",
496
+ }}
497
+ />,
498
+ ).toRender(
499
+ '<div style="display:inline-block;padding:2px 8px;border-radius:4px"></div>',
500
+ );
501
+ });
502
+
503
+ it("handles numbers and mixed style values", () => {
504
+ expect(
505
+ <div
506
+ style={{
507
+ fontSize: 12,
508
+ lineHeight: 1.5,
509
+ fontWeight: "600",
510
+ }}
511
+ />,
512
+ ).toRender(
513
+ '<div style="font-size:12;line-height:1.5;font-weight:600"></div>',
514
+ );
515
+ });
516
+
517
+ it("converts camelCase CSS properties", () => {
518
+ expect(
519
+ <div
520
+ style={{
521
+ backgroundColor: "#fff",
522
+ marginTop: "10px",
523
+ }}
524
+ />,
525
+ ).toRender('<div style="background-color:#fff;margin-top:10px"></div>');
526
+ });
527
+
528
+ it("ignores null and undefined style values", () => {
529
+ expect(
530
+ <div
531
+ style={{
532
+ color: "red",
533
+ padding: null,
534
+ margin: undefined,
535
+ }}
536
+ />,
537
+ ).toRender('<div style="color:red"></div>');
538
+ });
539
+ });