@server/next 0.37.0 → 0.38.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.
Files changed (3) hide show
  1. package/index.d.ts +17 -21
  2. package/index.js +266 -193
  3. package/package.json +20 -3
package/index.d.ts CHANGED
@@ -5,15 +5,6 @@ type LimitOptions = {
5
5
  minSize?: number | string;
6
6
  fileType?: string[];
7
7
  };
8
- declare class UploadPipeline {
9
- private _bucket;
10
- private _limits;
11
- constructor(bucket?: Bucket | string | null);
12
- limit(options: LimitOptions): this;
13
- store(bucket: Bucket | string): this;
14
- processFile(originalName: string, data: Buffer, contentType: string): Promise<UploadedFile>;
15
- }
16
- declare function upload(bucket?: Bucket | string | null): UploadPipeline;
17
8
 
18
9
  type CookieOptions = string | string[] | Cookie | Cookie[] | null;
19
10
  interface ResponseData {
@@ -26,12 +17,12 @@ declare class Reply$1 {
26
17
  status(status: number): this;
27
18
  type(type?: string): this;
28
19
  download(name?: string): this;
29
- headers(key: string | Record<string, string>, value?: string): this;
20
+ headers(key: string | Record<string, string | string[]>, value?: string | string[]): this;
30
21
  cache(value: CacheOption): this;
31
22
  cookies(key: string | Record<string, CookieOptions>, value?: CookieOptions): this;
32
23
  json(body: unknown): Response;
33
24
  redirect(path: string): Response;
34
- file(path: string): Promise<Response>;
25
+ file(path: string | BucketFile): Promise<Response>;
35
26
  send(body?: string | Buffer | ReadableStream | any): Response;
36
27
  }
37
28
  type Params<K extends keyof Reply$1> = Reply$1[K] extends (...args: infer A) => any ? A : never;
@@ -90,17 +81,16 @@ type Cookie = {
90
81
  };
91
82
  type RouterMethod = "*" | Method;
92
83
  type FileInfo = {
93
- exists: boolean;
94
84
  size: number;
95
- date: Date | null;
96
- type?: string | null;
85
+ type: string | null;
86
+ modified: Date;
97
87
  };
98
88
  type BucketFile = {
99
89
  readonly path: string;
100
- readonly id: string;
101
90
  readonly name: string;
91
+ readonly type?: string;
102
92
  exists(): Promise<boolean>;
103
- info?(): Promise<FileInfo>;
93
+ info?(): Promise<FileInfo | null>;
104
94
  write(content: string | Buffer | ReadableStream, options?: {
105
95
  type?: string;
106
96
  }): Promise<void>;
@@ -115,11 +105,13 @@ type Bucket = {
115
105
  };
116
106
  type UploadedFile = {
117
107
  name: string;
118
- id: string;
119
108
  path: string;
120
109
  type: string;
121
110
  size: number;
122
111
  };
112
+ type UploadOptions = LimitOptions & {
113
+ bucket: string | Bucket;
114
+ };
123
115
  type CorsSettings = {
124
116
  origin: string | boolean;
125
117
  methods: string;
@@ -193,6 +185,7 @@ type SecurityOptions = {
193
185
  referrerPolicy?: boolean | string;
194
186
  hsts?: boolean | string;
195
187
  xssProtection?: boolean;
188
+ traversalProtection?: boolean;
196
189
  csp?: boolean | string;
197
190
  coop?: boolean | string;
198
191
  corp?: boolean | string;
@@ -200,6 +193,7 @@ type SecurityOptions = {
200
193
  };
201
194
  type SecuritySettings = {
202
195
  trustProxy: boolean;
196
+ traversalProtection: boolean;
203
197
  headers: Record<string, string>;
204
198
  hsts: string | null;
205
199
  };
@@ -209,7 +203,7 @@ type Options = {
209
203
  port?: number;
210
204
  secret?: string;
211
205
  public?: string | Bucket;
212
- uploads?: string | Bucket | UploadPipeline;
206
+ uploads?: string | Bucket | UploadOptions;
213
207
  store?: KVStore;
214
208
  cookies?: KVStore;
215
209
  session?: KVStore | {
@@ -230,7 +224,9 @@ type Settings = {
230
224
  port: number;
231
225
  secret: string;
232
226
  public?: Bucket;
233
- uploads?: Bucket | UploadPipeline;
227
+ uploads?: ({
228
+ bucket: Bucket;
229
+ } & LimitOptions) | null;
234
230
  store?: KVStore;
235
231
  cookies?: KVStore;
236
232
  session?: {
@@ -301,7 +297,7 @@ type Context<Params extends Record<string, string | undefined> = Record<string,
301
297
  };
302
298
  app: Server;
303
299
  };
304
- type InlineReply = Response | Reply | {
300
+ type InlineReply = Response | Reply | BucketFile | {
305
301
  body: string;
306
302
  headers?: Headers;
307
303
  } | SerializableValue | JSX.Element | Buffer | ReadableStream;
@@ -504,4 +500,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
504
500
  }
505
501
  declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
506
502
 
507
- export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type BodyOption, type Bucket, type BucketFile, type BunEnv, type CacheOption, type Context, type Cookie, type CorsSettings, type ExtractPathParams, type FileInfo, type InferParamType, type InlineReply, type KVStore, type LimitOptions, type LogLevel, type Logger, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type Provider, type Route, type RouteOptions, type RouterMethod, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type Strategy, type Time, UploadPipeline, type UploadedFile, cache, cookies, server as default, download, file, headers, json, redirect, router, send, status, type, upload };
503
+ export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type BodyOption, type Bucket, type BucketFile, type BunEnv, type CacheOption, type Context, type Cookie, type CorsSettings, type ExtractPathParams, type FileInfo, type InferParamType, type InlineReply, type KVStore, type LogLevel, type Logger, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type Provider, type Route, type RouteOptions, type RouterMethod, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type Strategy, type Time, type UploadOptions, type UploadedFile, cache, cookies, server as default, download, file, headers, json, redirect, router, send, status, type };
package/index.js CHANGED
@@ -39,6 +39,10 @@ var ServerError_default = TypedServerError;
39
39
 
40
40
  // src/errors/index.ts
41
41
  ServerError_default.extend({
42
+ PATH_TRAVERSAL: {
43
+ status: 400,
44
+ message: "The route param '{param}' tries to climb the path ('{value}'). If this route legitimately receives paths, set security: { traversalProtection: false }"
45
+ },
42
46
  NO_STORE: "You need a 'store' to write 'ctx.session'",
43
47
  NO_STORE_WRITE: "You need a 'store' to write 'ctx.session.{key}'",
44
48
  NO_STORE_READ: "You need a 'store' to read 'ctx.session.{key}'",
@@ -76,6 +80,7 @@ ServerError_default.extend({
76
80
  REGISTER_INVALID_PASSWORD: "The password you wrote is not correct",
77
81
  REGISTER_EMAIL_EXISTS: "Email is already registered"
78
82
  });
83
+ var errors_default = ServerError_default;
79
84
 
80
85
  // src/polyfill.ts
81
86
  globalThis.env = {};
@@ -98,105 +103,6 @@ var StatusError = class extends Error {
98
103
  }
99
104
  };
100
105
 
101
- // src/helpers/bucket.ts
102
- import * as fs from "fs";
103
- import * as fsp from "fs/promises";
104
- import * as path from "path";
105
- function localBucket(root) {
106
- const base = path.resolve(root);
107
- const resolveKey = (name) => {
108
- if (!name) throw new Error("File name is required");
109
- const full = path.resolve(base, name.replace(/^\/+/, ""));
110
- if (full !== base && !full.startsWith(base + path.sep)) {
111
- throw new Error(`Path "${name}" escapes the bucket root`);
112
- }
113
- return full;
114
- };
115
- const file2 = (name, win) => {
116
- const full = resolveKey(name);
117
- const read = () => {
118
- let opts;
119
- if (win) {
120
- opts = { start: win.start };
121
- if (Number.isFinite(win.end)) opts.end = Math.max(win.start, win.end - 1);
122
- }
123
- const nodeStream = fs.createReadStream(full, opts);
124
- return new ReadableStream({
125
- start(controller) {
126
- nodeStream.on("data", (chunk) => controller.enqueue(chunk));
127
- nodeStream.on("end", () => controller.close());
128
- nodeStream.on("error", (err) => controller.error(err));
129
- },
130
- cancel() {
131
- nodeStream.destroy();
132
- }
133
- });
134
- };
135
- return {
136
- path: full,
137
- id: name.replace(/^\/+/, ""),
138
- name: path.basename(name),
139
- async exists() {
140
- const stats = await fsp.stat(full).catch(() => null);
141
- return !!stats?.isFile();
142
- },
143
- async info() {
144
- const stats = await fsp.stat(full).catch(() => null);
145
- const exists = !!stats?.isFile();
146
- const total = stats?.size ?? 0;
147
- const size = win ? Math.max(0, Math.min(win.end, total) - win.start) : total;
148
- return { exists, size, date: stats?.mtime ?? null };
149
- },
150
- // Read-only view of [start, end), composed relative to the current window.
151
- slice(start, end) {
152
- const base2 = win?.start ?? 0;
153
- const cap = win?.end ?? Number.POSITIVE_INFINITY;
154
- const s = Math.min(cap, base2 + Math.max(0, start));
155
- const e = end === void 0 ? cap : Math.min(cap, base2 + end);
156
- return file2(name, { start: s, end: e });
157
- },
158
- async write(content) {
159
- await fsp.mkdir(path.dirname(full), { recursive: true });
160
- if (content instanceof ReadableStream) {
161
- const writable = fs.createWriteStream(full);
162
- for await (const chunk of content) {
163
- writable.write(chunk);
164
- }
165
- await new Promise((resolve2, reject) => {
166
- writable.on("error", reject);
167
- writable.end(() => resolve2());
168
- });
169
- return;
170
- }
171
- await fsp.writeFile(full, content);
172
- },
173
- stream() {
174
- return read();
175
- },
176
- async bytes() {
177
- if (win) return new Uint8Array(await new Response(read()).arrayBuffer());
178
- return new Uint8Array(await fsp.readFile(full));
179
- },
180
- async remove() {
181
- await fsp.unlink(full).catch(() => {
182
- });
183
- }
184
- };
185
- };
186
- return {
187
- file: file2,
188
- folder: (prefix) => localBucket(path.join(base, prefix))
189
- };
190
- }
191
- function bucket(root) {
192
- if (!root) return null;
193
- if (typeof root === "string") return localBucket(root);
194
- if (typeof root.file === "function") return root;
195
- throw new Error(
196
- "Invalid bucket: pass a directory path or a `bucket` instance (with .file())"
197
- );
198
- }
199
-
200
106
  // src/helpers/createId.ts
201
107
  var alphabet = "useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict";
202
108
  var random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
@@ -263,60 +169,35 @@ async function saveFileToBucket(originalName, data, bucket2, contentType) {
263
169
  await file2.write(data, { type: contentType });
264
170
  return {
265
171
  name: originalName,
266
- id,
267
172
  path: file2.path,
268
173
  type: contentType,
269
174
  size: data.length
270
175
  };
271
176
  }
272
- var UploadPipeline = class {
273
- _bucket;
274
- _limits = {};
275
- constructor(bucket2) {
276
- this._bucket = bucket(bucket2 ?? void 0);
277
- }
278
- limit(options) {
279
- this._limits = { ...this._limits, ...options };
280
- return this;
177
+ function validateFile(originalName, data, contentType, limits) {
178
+ const { maxSize, minSize, fileType } = limits;
179
+ if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
180
+ throw new Error(
181
+ `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
182
+ );
281
183
  }
282
- store(bucket2) {
283
- this._bucket = bucket(bucket2);
284
- return this;
184
+ if (minSize !== void 0 && data.length < parseBytes(minSize)) {
185
+ throw new Error(
186
+ `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
187
+ );
285
188
  }
286
- async processFile(originalName, data, contentType) {
287
- const { maxSize, minSize, fileType } = this._limits;
288
- if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
289
- throw new Error(
290
- `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
291
- );
292
- }
293
- if (minSize !== void 0 && data.length < parseBytes(minSize)) {
294
- throw new Error(
295
- `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
296
- );
297
- }
298
- if (fileType && fileType.length > 0) {
299
- const ext2 = getExt(originalName);
300
- const mime = contentType.toLowerCase();
301
- const allowed = fileType.some(
302
- (t) => t.toLowerCase() === mime || t.toLowerCase() === ext2
303
- );
304
- if (!allowed) {
305
- throw new Error(
306
- `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
307
- );
308
- }
309
- }
310
- if (!this._bucket) {
189
+ if (fileType && fileType.length > 0) {
190
+ const ext2 = getExt(originalName);
191
+ const mime = contentType.toLowerCase();
192
+ const allowed = fileType.some(
193
+ (t) => t.toLowerCase() === mime || t.toLowerCase() === ext2
194
+ );
195
+ if (!allowed) {
311
196
  throw new Error(
312
- `No destination configured. Pass a bucket to upload() or call .store()`
197
+ `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
313
198
  );
314
199
  }
315
- return saveFileToBucket(originalName, data, this._bucket, contentType);
316
200
  }
317
- };
318
- function upload(bucket2) {
319
- return new UploadPipeline(bucket2);
320
201
  }
321
202
 
322
203
  // src/helpers/bodyLimit.ts
@@ -353,28 +234,28 @@ var mimes_default = {
353
234
  bz2: "application/x-bzip2",
354
235
  cda: "application/x-cdf",
355
236
  csh: "application/x-csh",
356
- css: "text/css",
357
- csv: "text/csv",
237
+ css: "text/css; charset=utf-8",
238
+ csv: "text/csv; charset=utf-8",
358
239
  doc: "application/msword",
359
240
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
360
241
  eot: "application/vnd.ms-fontobject",
361
242
  epub: "application/epub+zip",
362
243
  gz: "application/gzip",
363
244
  gif: "image/gif",
364
- htm: "text/html",
365
- html: "text/html",
245
+ htm: "text/html; charset=utf-8",
246
+ html: "text/html; charset=utf-8",
366
247
  ico: "image/vnd.microsoft.icon",
367
- ics: "text/calendar",
248
+ ics: "text/calendar; charset=utf-8",
368
249
  jar: "application/java-archive",
369
250
  jpeg: "image/jpeg",
370
251
  jpg: "image/jpeg",
371
- js: "text/javascript",
252
+ js: "text/javascript; charset=utf-8",
372
253
  json: "application/json",
373
254
  jsonld: "application/ld+json",
374
- md: "text/markdown",
255
+ md: "text/markdown; charset=utf-8",
375
256
  mid: "audio/midi",
376
257
  midi: "audio/midi",
377
- mjs: "text/javascript",
258
+ mjs: "text/javascript; charset=utf-8",
378
259
  mp3: "audio/mpeg",
379
260
  mp4: "video/mp4",
380
261
  mpeg: "video/mpeg",
@@ -397,12 +278,12 @@ var mimes_default = {
397
278
  sh: "application/x-sh",
398
279
  svg: "image/svg+xml",
399
280
  tar: "application/x-tar",
400
- text: "text/plain",
281
+ text: "text/plain; charset=utf-8",
401
282
  tif: "image/tiff",
402
283
  tiff: "image/tiff",
403
284
  ts: "video/mp2t",
404
285
  ttf: "font/ttf",
405
- txt: "text/plain",
286
+ txt: "text/plain; charset=utf-8",
406
287
  vsd: "application/vnd.visio",
407
288
  wav: "audio/wav",
408
289
  weba: "audio/webm",
@@ -499,15 +380,15 @@ function addField(body, name, value) {
499
380
  if (!Array.isArray(body[name])) body[name] = [body[name]];
500
381
  body[name].push(value);
501
382
  }
502
- function startPart(headerStr, dest) {
383
+ function startPart(headerStr, bucket2, limits) {
503
384
  const name = getMatching(headerStr, /name="(.+?)"/).trim().replace(/\[\]$/, "");
504
385
  if (!name) return { kind: "skip" };
505
386
  const filename = getMatching(headerStr, /filename="(.+?)"/).trim();
506
387
  if (!filename) return { kind: "text", name, chunks: [] };
507
388
  const type2 = getMatching(headerStr, /Content-Type:\s*([^\r\n]+)/i).trim() || "application/octet-stream";
508
- if (!dest) return { kind: "drop" };
509
- if (dest instanceof UploadPipeline) {
510
- return { kind: "pipefile", name, filename, type: type2, pipeline: dest, chunks: [] };
389
+ if (!bucket2) return { kind: "drop" };
390
+ if (limits) {
391
+ return { kind: "validated", name, filename, type: type2, bucket: bucket2, limits, chunks: [] };
511
392
  }
512
393
  const id = `${createId()}${getExt(filename)}`;
513
394
  let controller;
@@ -516,7 +397,7 @@ function startPart(headerStr, dest) {
516
397
  controller = c;
517
398
  }
518
399
  });
519
- const file2 = dest.file(id);
400
+ const file2 = bucket2.file(id);
520
401
  return {
521
402
  kind: "file",
522
403
  name,
@@ -531,7 +412,7 @@ function startPart(headerStr, dest) {
531
412
  }
532
413
  function feedPart(part, data) {
533
414
  if (data.length === 0) return;
534
- if (part.kind === "text" || part.kind === "pipefile") part.chunks.push(data);
415
+ if (part.kind === "text" || part.kind === "validated") part.chunks.push(data);
535
416
  else if (part.kind === "file") {
536
417
  part.controller.enqueue(data);
537
418
  part.size += data.length;
@@ -542,16 +423,16 @@ async function endPart(part, body) {
542
423
  const buf = Buffer.concat(part.chunks);
543
424
  const value = isProbablyText(buf) ? buf.toString("utf-8").trim() : buf;
544
425
  addField(body, part.name, value);
545
- } else if (part.kind === "pipefile") {
426
+ } else if (part.kind === "validated") {
546
427
  const buf = Buffer.concat(part.chunks);
547
- const ref = await part.pipeline.processFile(part.filename, buf, part.type);
428
+ validateFile(part.filename, buf, part.type, part.limits);
429
+ const ref = await saveFileToBucket(part.filename, buf, part.bucket, part.type);
548
430
  addField(body, part.name, ref);
549
431
  } else if (part.kind === "file") {
550
432
  part.controller.close();
551
433
  await part.write;
552
434
  addField(body, part.name, {
553
435
  name: part.filename,
554
- id: part.id,
555
436
  path: part.file.path,
556
437
  type: part.type,
557
438
  size: part.size
@@ -559,7 +440,7 @@ async function endPart(part, body) {
559
440
  }
560
441
  }
561
442
  var BREAK = Buffer.from("\r\n\r\n");
562
- async function parseMultipart(stream, boundary, dest, max = INF) {
443
+ async function parseMultipart(stream, boundary, bucket2, limits, max = INF) {
563
444
  const delim = Buffer.from(`\r
564
445
  --${boundary}`);
565
446
  const body = {};
@@ -596,7 +477,7 @@ async function parseMultipart(stream, boundary, dest, max = INF) {
596
477
  } else if (state === "headers") {
597
478
  const i = buf.indexOf(BREAK);
598
479
  if (i === -1) break;
599
- part = startPart(buf.subarray(0, i).toString("utf-8"), dest);
480
+ part = startPart(buf.subarray(0, i).toString("utf-8"), bucket2, limits);
600
481
  buf = buf.subarray(i + BREAK.length);
601
482
  state = "body";
602
483
  advanced = true;
@@ -642,12 +523,25 @@ async function streamToBucket(stream, type2, bucket2) {
642
523
  controller.close();
643
524
  await write;
644
525
  if (!size) return void 0;
645
- return { name: id, id, path: file2.path, type: type2, size };
526
+ return { name: id, path: file2.path, type: type2, size };
646
527
  }
647
528
  async function parseBody(input, contentType, dest, max = INF) {
648
529
  const type2 = Array.isArray(contentType) ? contentType[0] : contentType;
530
+ let bucket2;
531
+ let limits;
532
+ if (dest && "bucket" in dest) {
533
+ bucket2 = dest.bucket;
534
+ const { maxSize, minSize, fileType } = dest;
535
+ if (maxSize != null || minSize != null || fileType != null) {
536
+ limits = { maxSize, minSize, fileType };
537
+ }
538
+ } else {
539
+ bucket2 = dest;
540
+ }
649
541
  const boundary = type2 && /multipart\/form-data/i.test(type2) ? getBoundary(type2) : null;
650
- if (boundary) return parseMultipart(toStream(input), boundary, dest, max);
542
+ if (boundary) {
543
+ return parseMultipart(toStream(input), boundary, bucket2, limits, max);
544
+ }
651
545
  if (!type2 || /^text\//i.test(type2)) {
652
546
  const buf = await toBuffer(input, max);
653
547
  return buf.length ? buf.toString("utf-8") : void 0;
@@ -660,15 +554,18 @@ async function parseBody(input, contentType, dest, max = INF) {
660
554
  const buf = await toBuffer(input, max);
661
555
  return buf.length ? parseUrlEncoded(buf.toString("utf-8")) : void 0;
662
556
  }
663
- if (!dest) {
557
+ if (!bucket2) {
664
558
  const buf = await toBuffer(input, max);
665
559
  return buf.length ? buf : void 0;
666
560
  }
667
- if (dest instanceof UploadPipeline) {
561
+ if (limits) {
668
562
  const buf = await toBuffer(input);
669
- return buf.length ? dest.processFile(`upload${extFromType(type2)}`, buf, type2) : void 0;
563
+ if (!buf.length) return void 0;
564
+ const name = `upload${extFromType(type2)}`;
565
+ validateFile(name, buf, type2, limits);
566
+ return saveFileToBucket(name, buf, bucket2, type2);
670
567
  }
671
- return streamToBucket(toStream(input), type2, dest);
568
+ return streamToBucket(toStream(input), type2, bucket2);
672
569
  }
673
570
 
674
571
  // src/helpers/body.ts
@@ -765,7 +662,7 @@ function normalizeExpires(expires) {
765
662
  function createCookies(key, val) {
766
663
  if (val.value === null) val.expires = EXPIRED;
767
664
  const { value, path: path2, expires, maxAge, httpOnly, secure, sameSite } = val;
768
- let str = `${key}=${value || ""};Path=${path2 || "/"}`;
665
+ let str = `${key}=${encodeURIComponent(value ?? "")};Path=${path2 || "/"}`;
769
666
  if (typeof expires !== "undefined") str += `;Expires=${normalizeExpires(expires)}`;
770
667
  if (typeof maxAge === "number") str += `;Max-Age=${maxAge}`;
771
668
  if (httpOnly) str += ";HttpOnly";
@@ -828,6 +725,27 @@ function clientIp(headers2, opts = {}) {
828
725
  return normalize(remoteAddress);
829
726
  }
830
727
 
728
+ // src/helpers/disposition.ts
729
+ var encodeExt = (name) => encodeURIComponent(name).replace(
730
+ /['()*]/g,
731
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
732
+ );
733
+ function disposition(name) {
734
+ if (!name) return "attachment";
735
+ const clean = name.replace(/[\r\n]/g, "").split(/[\\/]/).pop() || "";
736
+ if (!clean) return "attachment";
737
+ const ascii = clean.replace(/[^\x20-\x7e]/g, "?");
738
+ const value = `attachment; filename="${ascii.replace(/["\\]/g, "\\$&")}"`;
739
+ if (clean === ascii) return value;
740
+ return `${value}; filename*=UTF-8''${encodeExt(clean)}`;
741
+ }
742
+
743
+ // src/helpers/isHtml.ts
744
+ var TAG = /^\s*<[a-zA-Z!/]/;
745
+ function isHtml(body) {
746
+ return TAG.test(body);
747
+ }
748
+
831
749
  // src/helpers/isReadableStream.ts
832
750
  function isReadableStream(obj) {
833
751
  return obj !== null && typeof obj === "object" && typeof obj.pipe === "function" && typeof obj.read === "function" && typeof obj.on === "function";
@@ -855,8 +773,7 @@ var Reply = class {
855
773
  download(name) {
856
774
  const ext2 = name?.split(".").pop();
857
775
  if (ext2 && !this.res.headers.get("content-type")) this.type(ext2);
858
- const filename = name ? `; filename="${encodeURIComponent(name)}"` : "";
859
- return this.headers("content-disposition", `attachment${filename}`);
776
+ return this.headers("content-disposition", disposition(name));
860
777
  }
861
778
  headers(key, value) {
862
779
  if (typeof key !== "string") {
@@ -864,10 +781,15 @@ var Reply = class {
864
781
  return this;
865
782
  }
866
783
  if (Array.isArray(value)) {
867
- Object.values(value).map((val) => this.headers(key, val));
784
+ this.res.headers.delete(key);
785
+ for (const val of value) this.res.headers.append(key, val);
868
786
  return this;
869
787
  }
870
- this.res.headers.append(key, value);
788
+ if (key.toLowerCase() === "set-cookie") {
789
+ this.res.headers.append(key, value);
790
+ } else {
791
+ this.res.headers.set(key, value);
792
+ }
871
793
  return this;
872
794
  }
873
795
  cache(value) {
@@ -889,21 +811,31 @@ var Reply = class {
889
811
  return this.headers("set-cookie", createCookies(key, value));
890
812
  }
891
813
  json(body) {
892
- return this.headers("content-type", "application/json").send(
893
- JSON.stringify(body)
894
- );
814
+ if (body === void 0) body = null;
815
+ if (!this.res.headers.get("content-type")) {
816
+ this.res.headers.set("content-type", "application/json");
817
+ }
818
+ return this.send(JSON.stringify(body));
895
819
  }
896
820
  redirect(path2) {
897
- return this.headers("location", path2).status(302).send();
821
+ this.headers("location", path2);
822
+ if (this.res.status == null) this.res.status = 302;
823
+ return this.send();
898
824
  }
899
825
  async file(path2) {
826
+ if (typeof path2 !== "string") {
827
+ if (!await path2.exists()) return this.status(404).send();
828
+ return this.type(path2.type).send(path2.stream());
829
+ }
830
+ if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path2)) return this.status(404).send();
900
831
  try {
901
832
  const fs2 = await import("fs");
902
833
  const ext2 = path2.split(".").pop();
834
+ await fs2.promises.access(path2);
903
835
  const stream = fs2.createReadStream(path2);
904
836
  return this.type(ext2).send(stream);
905
837
  } catch (error) {
906
- if (error.code === "ENOENT") {
838
+ if (error.code === "ENOENT" || error.code === "EISDIR") {
907
839
  return this.status(404).send();
908
840
  }
909
841
  throw error;
@@ -914,10 +846,10 @@ var Reply = class {
914
846
  if (status2 === 101 || status2 === 204 || status2 === 205 || status2 === 304) {
915
847
  return new Response(null, { status: status2, headers: headers2 });
916
848
  }
849
+ if (body === null) body = "";
917
850
  if (typeof body === "string") {
918
851
  if (!headers2.get("content-type")) {
919
- const isHtml = body.trim().startsWith("<");
920
- headers2.set("content-type", isHtml ? "text/html" : "text/plain");
852
+ headers2.set("content-type", isHtml(body) ? mimes_default.html : mimes_default.text);
921
853
  }
922
854
  if (!headers2.has("content-length")) {
923
855
  headers2.set("content-length", String(Buffer.byteLength(body)));
@@ -925,7 +857,7 @@ var Reply = class {
925
857
  return new Response(body, { status: status2, headers: headers2 });
926
858
  }
927
859
  const name = body?.constructor?.name;
928
- if (name === "Buffer") {
860
+ if (body instanceof Uint8Array) {
929
861
  if (!headers2.has("content-length")) {
930
862
  headers2.set("content-length", String(body.length));
931
863
  }
@@ -1517,6 +1449,106 @@ function parseAuthOptions(auth2, all) {
1517
1449
  };
1518
1450
  }
1519
1451
 
1452
+ // src/helpers/bucket.ts
1453
+ import * as fs from "fs";
1454
+ import * as fsp from "fs/promises";
1455
+ import * as path from "path";
1456
+ function localBucket(root, prefix = "") {
1457
+ const base = path.resolve(root);
1458
+ const resolveKey = (name) => {
1459
+ if (!name) throw new Error("File name is required");
1460
+ const full = path.resolve(base, name.replace(/^\/+/, ""));
1461
+ if (full !== base && !full.startsWith(base + path.sep)) {
1462
+ throw new Error(`Path "${name}" escapes the bucket root`);
1463
+ }
1464
+ return full;
1465
+ };
1466
+ const file2 = (name, win) => {
1467
+ const full = resolveKey(name);
1468
+ const key = prefix + name.replace(/^\/+/, "");
1469
+ const type2 = mimes_default[path.extname(name).slice(1).toLowerCase()];
1470
+ const read = () => {
1471
+ let opts;
1472
+ if (win) {
1473
+ opts = { start: win.start };
1474
+ if (Number.isFinite(win.end)) opts.end = Math.max(win.start, win.end - 1);
1475
+ }
1476
+ const nodeStream = fs.createReadStream(full, opts);
1477
+ return new ReadableStream({
1478
+ start(controller) {
1479
+ nodeStream.on("data", (chunk) => controller.enqueue(chunk));
1480
+ nodeStream.on("end", () => controller.close());
1481
+ nodeStream.on("error", (err) => controller.error(err));
1482
+ },
1483
+ cancel() {
1484
+ nodeStream.destroy();
1485
+ }
1486
+ });
1487
+ };
1488
+ return {
1489
+ path: key,
1490
+ name: path.basename(name),
1491
+ type: type2,
1492
+ async exists() {
1493
+ const stats = await fsp.stat(full).catch(() => null);
1494
+ return !!stats?.isFile();
1495
+ },
1496
+ async info() {
1497
+ const stats = await fsp.stat(full).catch(() => null);
1498
+ if (!stats?.isFile()) return null;
1499
+ const size = win ? Math.max(0, Math.min(win.end, stats.size) - win.start) : stats.size;
1500
+ return { size, type: type2 ?? null, modified: stats.mtime };
1501
+ },
1502
+ // Read-only view of [start, end), composed relative to the current window.
1503
+ slice(start, end) {
1504
+ const base2 = win?.start ?? 0;
1505
+ const cap = win?.end ?? Number.POSITIVE_INFINITY;
1506
+ const s = Math.min(cap, base2 + Math.max(0, start));
1507
+ const e = end === void 0 ? cap : Math.min(cap, base2 + end);
1508
+ return file2(name, { start: s, end: e });
1509
+ },
1510
+ async write(content) {
1511
+ await fsp.mkdir(path.dirname(full), { recursive: true });
1512
+ if (content instanceof ReadableStream) {
1513
+ const writable = fs.createWriteStream(full);
1514
+ for await (const chunk of content) {
1515
+ writable.write(chunk);
1516
+ }
1517
+ await new Promise((resolve2, reject) => {
1518
+ writable.on("error", reject);
1519
+ writable.end(() => resolve2());
1520
+ });
1521
+ return;
1522
+ }
1523
+ await fsp.writeFile(full, content);
1524
+ },
1525
+ stream() {
1526
+ return read();
1527
+ },
1528
+ async bytes() {
1529
+ if (win) return new Uint8Array(await new Response(read()).arrayBuffer());
1530
+ return new Uint8Array(await fsp.readFile(full));
1531
+ },
1532
+ async remove() {
1533
+ await fsp.unlink(full).catch(() => {
1534
+ });
1535
+ }
1536
+ };
1537
+ };
1538
+ return {
1539
+ file: file2,
1540
+ folder: (sub) => localBucket(path.join(base, sub), `${prefix}${sub.replace(/^\/+|\/+$/g, "")}/`)
1541
+ };
1542
+ }
1543
+ function bucket(root) {
1544
+ if (!root) return null;
1545
+ if (typeof root === "string") return localBucket(root);
1546
+ if (typeof root.file === "function") return root;
1547
+ throw new Error(
1548
+ "Invalid bucket: pass a directory path or a `bucket` instance (with .file())"
1549
+ );
1550
+ }
1551
+
1520
1552
  // src/helpers/color.ts
1521
1553
  var map = {
1522
1554
  reset: 0,
@@ -1652,10 +1684,23 @@ function resolveSecurity(security) {
1652
1684
  }
1653
1685
  return {
1654
1686
  trustProxy: o.trustProxy ?? true,
1687
+ traversalProtection: off ? false : o.traversalProtection !== false,
1655
1688
  headers: headers2,
1656
1689
  hsts: off ? null : val(o.hsts, "max-age=15552000; includeSubDomains")
1657
1690
  };
1658
1691
  }
1692
+ var CLIMBS = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
1693
+ var ABSOLUTE = /^(?:[\\/]|[a-zA-Z]:)/;
1694
+ function checkTraversal(params, ctx) {
1695
+ if (!ctx.options.security?.traversalProtection) return;
1696
+ for (const param in params) {
1697
+ const value = params[param];
1698
+ if (typeof value !== "string") continue;
1699
+ if (CLIMBS.test(value) || ABSOLUTE.test(value)) {
1700
+ throw errors_default.PATH_TRAVERSAL({ param, value });
1701
+ }
1702
+ }
1703
+ }
1659
1704
  function applySecurity(res, ctx) {
1660
1705
  const security = ctx.options.security;
1661
1706
  if (!security) return;
@@ -1721,9 +1766,21 @@ function config(options = {}) {
1721
1766
  }
1722
1767
  settings.cors = cors2;
1723
1768
  }
1724
- settings.public = options.public ? bucket(options.public) : null;
1725
- settings.uploads = options.uploads instanceof UploadPipeline ? options.uploads : options.uploads ? bucket(options.uploads) : null;
1726
- if (options.favicon) settings.favicon = options.favicon;
1769
+ const publicDir = options.public || env2.PUBLIC;
1770
+ settings.public = publicDir ? bucket(publicDir) : null;
1771
+ const up = options.uploads;
1772
+ if (!up) {
1773
+ settings.uploads = null;
1774
+ } else if (typeof up === "object" && "bucket" in up) {
1775
+ const { bucket: bucket2, maxSize, minSize, fileType } = up;
1776
+ if (maxSize != null) parseBytes(maxSize);
1777
+ if (minSize != null) parseBytes(minSize);
1778
+ settings.uploads = { bucket: bucket(bucket2), maxSize, minSize, fileType };
1779
+ } else {
1780
+ settings.uploads = { bucket: bucket(up) };
1781
+ }
1782
+ const favicon2 = options.favicon || env2.FAVICON;
1783
+ if (favicon2) settings.favicon = favicon2;
1727
1784
  settings.store = options.store ?? null;
1728
1785
  settings.cookies = options.cookies ?? null;
1729
1786
  if (options.session) {
@@ -1879,6 +1936,16 @@ async function parseResponse(out, ctx) {
1879
1936
  if (out instanceof Blob) {
1880
1937
  out = new Response(out, { headers: { "Content-Type": out.type } });
1881
1938
  }
1939
+ if (out && typeof out.stream === "function" && typeof out.bytes === "function" && typeof out.exists === "function" && typeof out.name === "string") {
1940
+ if (!await out.exists()) {
1941
+ out = new Response(null, { status: 404 });
1942
+ } else {
1943
+ out = new Response(
1944
+ out.stream(),
1945
+ out.type ? { headers: { "content-type": out.type } } : void 0
1946
+ );
1947
+ }
1948
+ }
1882
1949
  if (out instanceof ReadableStream) {
1883
1950
  out = new Response(out);
1884
1951
  }
@@ -1889,7 +1956,7 @@ async function parseResponse(out, ctx) {
1889
1956
  out = new Response(void 0, { status: out });
1890
1957
  }
1891
1958
  if (typeof out === "string") {
1892
- const type2 = /^\s*</.test(out) ? "text/html" : "text/plain";
1959
+ const type2 = isHtml(out) ? mimes_default.html : mimes_default.text;
1893
1960
  out = new Response(out, {
1894
1961
  headers: {
1895
1962
  "content-type": type2,
@@ -2055,6 +2122,7 @@ async function getResponse(app, ctx) {
2055
2122
  if (Object.keys(route.options).length) {
2056
2123
  ctx.options = { ...app.settings, ...route.options };
2057
2124
  }
2125
+ checkTraversal(params, ctx);
2058
2126
  ctx.body = await resolveBody(ctx, ctx.options.body);
2059
2127
  for (const cb of route.fns) {
2060
2128
  if (typeof cb === "function") {
@@ -2154,7 +2222,12 @@ function parseCookies(cookies2) {
2154
2222
  return Object.fromEntries(
2155
2223
  cookieStr.split(/;\s*/).map((part) => {
2156
2224
  const [key, ...rest] = part.split("=");
2157
- return [key, decodeURIComponent(rest.join("="))];
2225
+ const value = rest.join("=");
2226
+ try {
2227
+ return [key, decodeURIComponent(value)];
2228
+ } catch {
2229
+ return [key, value];
2230
+ }
2158
2231
  })
2159
2232
  );
2160
2233
  }
@@ -2424,17 +2497,18 @@ async function assets(ctx) {
2424
2497
  try {
2425
2498
  const key = ctx.url.pathname.replace(/^\/+/, "");
2426
2499
  const file2 = ctx.options.public.file(key);
2427
- const meta = file2.info ? await file2.info() : null;
2428
- if (meta ? !meta.exists : !await file2.exists()) return;
2500
+ const info = file2.info?.bind(file2);
2501
+ const meta = info ? await info() : null;
2502
+ if (info ? !meta : !await file2.exists()) return;
2429
2503
  const ext2 = ctx.url.pathname.split(".").pop();
2430
2504
  const ctype = meta?.type || ext2;
2431
2505
  const headers2 = { "cache-control": CACHE_CONTROL };
2432
2506
  let tag;
2433
2507
  if (meta) {
2434
- const stamp = meta.date ? meta.date.getTime() : 0;
2508
+ const stamp = meta.modified ? meta.modified.getTime() : 0;
2435
2509
  tag = `W/"${meta.size.toString(16)}-${stamp.toString(16)}"`;
2436
2510
  headers2.etag = tag;
2437
- if (meta.date) headers2["last-modified"] = meta.date.toUTCString();
2511
+ if (meta.modified) headers2["last-modified"] = meta.modified.toUTCString();
2438
2512
  }
2439
2513
  const canRange = !!(meta && file2.slice);
2440
2514
  if (canRange) headers2["accept-ranges"] = "bytes";
@@ -3264,6 +3338,5 @@ export {
3264
3338
  router,
3265
3339
  send,
3266
3340
  status,
3267
- type,
3268
- upload
3341
+ type
3269
3342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
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": "github:franciscop/server-next",
@@ -44,14 +44,31 @@
44
44
  "home": "./docs/index.html",
45
45
  "menu": {
46
46
  "Documentation": "/documentation",
47
+ "Tutorials": "/tutorials",
47
48
  "Github": "https://github.com/franciscop/server-next"
48
- }
49
+ },
50
+ "documentation": {
51
+ "Documentation": [
52
+ "docs/0. Documentation.md",
53
+ "docs/1. Getting Started.md"
54
+ ],
55
+ "Guides": "docs/2. Guides.md",
56
+ "Options": "docs/3. Options.md",
57
+ "Router": "docs/4. Router.md",
58
+ "Context": "docs/5. Context.md",
59
+ "Reply": "docs/6. Reply.md",
60
+ "Authentication": "docs/7. Authentication.md",
61
+ "Testing": "docs/8. Testing.md",
62
+ "Platforms": "docs/9. Platforms.md",
63
+ "FAQ": "docs/A. FAQ.md"
64
+ },
65
+ "tutorials": "docs/tutorials"
49
66
  },
50
67
  "devDependencies": {
51
68
  "@types/bun": "^1.3.0",
52
69
  "@types/jest": "^30.0.0",
53
70
  "@types/node": "^24.10.0",
54
- "bucket": "^0.4.0",
71
+ "bucket": "^0.5.0",
55
72
  "bun": "^1.3.13",
56
73
  "check-dts": "^0.8.2",
57
74
  "jest": "^29.7.0",