@server/next 0.34.2 → 0.35.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 +21 -7
  2. package/index.js +278 -51
  3. package/package.json +2 -2
package/index.d.ts CHANGED
@@ -30,11 +30,15 @@ declare namespace JSX {
30
30
  }
31
31
  }
32
32
  type BodyMode = "parse" | "raw" | "stream";
33
+ type BodyOption = BodyMode | {
34
+ mode?: BodyMode;
35
+ max?: number | string | false;
36
+ };
33
37
  type RouteOptions = {
34
38
  tags?: string | string[];
35
39
  title?: string;
36
40
  description?: string;
37
- body?: BodyMode;
41
+ body?: BodyOption;
38
42
  };
39
43
  type Route = {
40
44
  path: string;
@@ -51,15 +55,23 @@ type Cookie = {
51
55
  sameSite?: "Strict" | "Lax" | "None";
52
56
  };
53
57
  type RouterMethod = "*" | Method;
58
+ type FileInfo = {
59
+ exists: boolean;
60
+ size: number;
61
+ date: Date | null;
62
+ type?: string | null;
63
+ };
54
64
  type BucketFile = {
55
65
  readonly path: string;
56
66
  readonly id: string;
57
67
  readonly name: string;
58
68
  exists(): Promise<boolean>;
69
+ info?(): Promise<FileInfo>;
59
70
  write(content: string | Buffer | ReadableStream, options?: {
60
71
  type?: string;
61
72
  }): Promise<void>;
62
73
  stream(): ReadableStream;
74
+ slice?(start: number, end?: number): BucketFile;
63
75
  bytes(): Promise<Uint8Array>;
64
76
  remove(): Promise<void>;
65
77
  };
@@ -102,7 +114,7 @@ type KVStore = {
102
114
  keys: () => Promise<string[]>;
103
115
  };
104
116
  type Provider = "email" | "github" | "google" | "microsoft" | "discord" | "facebook" | "apple";
105
- type Strategy = "cookie" | "jwt" | "token";
117
+ type Strategy = "cookie" | "jwt" | "token" | "key";
106
118
  type AuthSession = {
107
119
  id: string;
108
120
  provider: Provider;
@@ -115,9 +127,10 @@ type AuthUser<T = Record<string, any>> = T & {
115
127
  strategy: Strategy;
116
128
  email: string;
117
129
  };
118
- type AuthOption = `${Strategy}:${Provider}` | {
130
+ type AuthOption = `${Strategy}:${Provider}` | "key" | {
119
131
  strategy: Strategy;
120
- providers: Provider | Provider[];
132
+ providers?: Provider | Provider[];
133
+ key?: string;
121
134
  session?: KVStore;
122
135
  store?: KVStore;
123
136
  redirect?: string;
@@ -128,6 +141,7 @@ type AuthSettings = {
128
141
  strategy: Strategy;
129
142
  store: KVStore;
130
143
  session: KVStore;
144
+ key?: string;
131
145
  cleanUser: <T = AuthUser>(user: T) => T | Promise<T>;
132
146
  redirect: string;
133
147
  };
@@ -173,7 +187,7 @@ type Options = {
173
187
  log?: LogLevel | boolean;
174
188
  favicon?: string | BucketFile;
175
189
  security?: boolean | SecurityOptions;
176
- body?: BodyMode;
190
+ body?: BodyOption;
177
191
  };
178
192
  type Settings = {
179
193
  port: number;
@@ -192,7 +206,7 @@ type Settings = {
192
206
  log: Logger;
193
207
  favicon?: string | BucketFile;
194
208
  security: SecuritySettings;
195
- body: BodyMode;
209
+ body: BodyOption;
196
210
  };
197
211
  type Time = {
198
212
  (name: string): void;
@@ -478,4 +492,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
478
492
  }
479
493
  declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
480
494
 
481
- export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type Bucket, type BucketFile, type BunEnv, type Context, type Cookie, type CorsSettings, type ExtractPathParams, 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, cookies, server as default, download, file, headers, json, redirect, router, send, status, type, upload };
495
+ export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type BodyOption, type Bucket, type BucketFile, type BunEnv, 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, cookies, server as default, download, file, headers, json, redirect, router, send, status, type, upload };
package/index.js CHANGED
@@ -226,8 +226,26 @@ function localBucket(root) {
226
226
  }
227
227
  return full;
228
228
  };
229
- const file2 = (name) => {
229
+ const file2 = (name, win) => {
230
230
  const full = resolveKey(name);
231
+ const read = () => {
232
+ let opts;
233
+ if (win) {
234
+ opts = { start: win.start };
235
+ if (Number.isFinite(win.end)) opts.end = Math.max(win.start, win.end - 1);
236
+ }
237
+ const nodeStream = fs.createReadStream(full, opts);
238
+ return new ReadableStream({
239
+ start(controller) {
240
+ nodeStream.on("data", (chunk) => controller.enqueue(chunk));
241
+ nodeStream.on("end", () => controller.close());
242
+ nodeStream.on("error", (err) => controller.error(err));
243
+ },
244
+ cancel() {
245
+ nodeStream.destroy();
246
+ }
247
+ });
248
+ };
231
249
  return {
232
250
  path: full,
233
251
  id: name.replace(/^\/+/, ""),
@@ -236,6 +254,21 @@ function localBucket(root) {
236
254
  const stats = await fsp.stat(full).catch(() => null);
237
255
  return !!stats?.isFile();
238
256
  },
257
+ async info() {
258
+ const stats = await fsp.stat(full).catch(() => null);
259
+ const exists = !!stats?.isFile();
260
+ const total = stats?.size ?? 0;
261
+ const size = win ? Math.max(0, Math.min(win.end, total) - win.start) : total;
262
+ return { exists, size, date: stats?.mtime ?? null };
263
+ },
264
+ // Read-only view of [start, end), composed relative to the current window.
265
+ slice(start, end) {
266
+ const base2 = win?.start ?? 0;
267
+ const cap = win?.end ?? Number.POSITIVE_INFINITY;
268
+ const s = Math.min(cap, base2 + Math.max(0, start));
269
+ const e = end === void 0 ? cap : Math.min(cap, base2 + end);
270
+ return file2(name, { start: s, end: e });
271
+ },
239
272
  async write(content) {
240
273
  await fsp.mkdir(path.dirname(full), { recursive: true });
241
274
  if (content instanceof ReadableStream) {
@@ -252,19 +285,10 @@ function localBucket(root) {
252
285
  await fsp.writeFile(full, content);
253
286
  },
254
287
  stream() {
255
- const nodeStream = fs.createReadStream(full);
256
- return new ReadableStream({
257
- start(controller) {
258
- nodeStream.on("data", (chunk) => controller.enqueue(chunk));
259
- nodeStream.on("end", () => controller.close());
260
- nodeStream.on("error", (err) => controller.error(err));
261
- },
262
- cancel() {
263
- nodeStream.destroy();
264
- }
265
- });
288
+ return read();
266
289
  },
267
290
  async bytes() {
291
+ if (win) return new Uint8Array(await new Response(read()).arrayBuffer());
268
292
  return new Uint8Array(await fsp.readFile(full));
269
293
  },
270
294
  async remove() {
@@ -602,17 +626,34 @@ async function parseBody(input, contentType, dest) {
602
626
  return streamToBucket(toStream(input), type2, dest);
603
627
  }
604
628
 
629
+ // src/helpers/StatusError.ts
630
+ var StatusError = class extends Error {
631
+ status;
632
+ constructor(msg, status2 = 500) {
633
+ super(msg);
634
+ this.status = status2;
635
+ }
636
+ };
637
+
605
638
  // src/helpers/body.ts
639
+ var INF = Number.POSITIVE_INFINITY;
640
+ var resolveMax = (max) => max === false || max == null ? INF : parseBytes(max);
641
+ var tooLarge = (max) => new StatusError(`Request body exceeds the ${max}-byte limit`, 413);
606
642
  var sources = /* @__PURE__ */ new WeakMap();
607
643
  function setBodySource(ctx, source) {
608
644
  sources.set(ctx, source);
609
645
  }
610
- async function resolveBody(ctx, mode) {
646
+ async function resolveBody(ctx, body) {
611
647
  const source = sources.get(ctx);
612
648
  if (!source) return void 0;
649
+ const mode = typeof body === "string" ? body : body?.mode ?? "parse";
650
+ const max = resolveMax(typeof body === "object" ? body?.max : void 0);
651
+ const declared = Number(ctx.headers["content-length"]);
652
+ if (max !== INF && declared > max) throw tooLarge(max);
613
653
  if (mode === "stream") return source.getStream();
614
654
  if (mode === "raw") {
615
655
  const raw = await source.getBuffer();
656
+ if (raw.length > max) throw tooLarge(max);
616
657
  if (!raw.length) return void 0;
617
658
  if (!ctx.headers["content-length"]) {
618
659
  ctx.headers["content-length"] = String(raw.length);
@@ -626,11 +667,12 @@ async function resolveBody(ctx, mode) {
626
667
  new TransformStream({
627
668
  transform(chunk, controller) {
628
669
  size += chunk.byteLength;
670
+ if (size > max) return controller.error(tooLarge(max));
629
671
  controller.enqueue(chunk);
630
672
  }
631
673
  })
632
674
  );
633
- const body = await parseBody(
675
+ const parsed = await parseBody(
634
676
  counted,
635
677
  ctx.headers["content-type"],
636
678
  ctx.options.uploads
@@ -638,7 +680,7 @@ async function resolveBody(ctx, mode) {
638
680
  if (size && !ctx.headers["content-length"]) {
639
681
  ctx.headers["content-length"] = String(size);
640
682
  }
641
- return body;
683
+ return parsed;
642
684
  }
643
685
 
644
686
  // src/helpers/clientIp.ts
@@ -787,6 +829,73 @@ var json = (...args) => r().json(...args);
787
829
  var file = (...args) => r().file(...args);
788
830
  var redirect = (...args) => r().redirect(...args);
789
831
 
832
+ // src/helpers/jwt.ts
833
+ var enc = new TextEncoder();
834
+ var dec = new TextDecoder();
835
+ var b64url = (data) => {
836
+ const bytes = typeof data === "string" ? enc.encode(data) : data;
837
+ let bin = "";
838
+ for (const b of bytes) bin += String.fromCharCode(b);
839
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
840
+ };
841
+ var unb64url = (seg) => {
842
+ let b64 = seg.replace(/-/g, "+").replace(/_/g, "/");
843
+ b64 += "=".repeat((4 - b64.length % 4) % 4);
844
+ const bin = atob(b64);
845
+ const bytes = new Uint8Array(bin.length);
846
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
847
+ return bytes;
848
+ };
849
+ var hmacKey = (secret) => crypto.subtle.importKey(
850
+ "raw",
851
+ enc.encode(secret),
852
+ { name: "HMAC", hash: "SHA-256" },
853
+ false,
854
+ ["sign", "verify"]
855
+ );
856
+ async function signJwt(payload, secret, expires) {
857
+ const now = Math.floor(Date.now() / 1e3);
858
+ const claims = {
859
+ iat: now,
860
+ ...expires ? { exp: now + expires } : {},
861
+ ...payload
862
+ };
863
+ const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
864
+ const body = b64url(JSON.stringify(claims));
865
+ const data = `${head}.${body}`;
866
+ const key = await hmacKey(secret);
867
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
868
+ return `${data}.${b64url(new Uint8Array(sig))}`;
869
+ }
870
+ async function verifyJwt(token, secret) {
871
+ const parts = token.split(".");
872
+ if (parts.length !== 3) return null;
873
+ const [head, body, sig] = parts;
874
+ let header;
875
+ try {
876
+ header = JSON.parse(dec.decode(unb64url(head)));
877
+ } catch {
878
+ return null;
879
+ }
880
+ if (header?.alg !== "HS256") return null;
881
+ const key = await hmacKey(secret);
882
+ const ok = await crypto.subtle.verify(
883
+ "HMAC",
884
+ key,
885
+ unb64url(sig),
886
+ enc.encode(`${head}.${body}`)
887
+ );
888
+ if (!ok) return null;
889
+ let payload;
890
+ try {
891
+ payload = JSON.parse(dec.decode(unb64url(body)));
892
+ } catch {
893
+ return null;
894
+ }
895
+ if (payload?.exp && Math.floor(Date.now() / 1e3) >= payload.exp) return null;
896
+ return payload;
897
+ }
898
+
790
899
  // src/auth/finishLogin.ts
791
900
  async function finishLogin(ctx, input) {
792
901
  const settings = ctx.options.auth;
@@ -807,7 +916,13 @@ async function finishLogin(ctx, input) {
807
916
  }
808
917
  user = await cleanUser(user);
809
918
  if (input.store !== false) await settings.store.set(key, user);
810
- await settings.session.set(auth2.id, auth2, { expires: "1w" });
919
+ if (!strategy.includes("jwt")) {
920
+ await settings.session.set(auth2.id, auth2, { expires: "1w" });
921
+ }
922
+ if (strategy.includes("jwt")) {
923
+ const token = await signJwt(auth2, ctx.options.secret, 7 * 24 * 60 * 60);
924
+ return status(201).json({ ...user, token });
925
+ }
811
926
  if (strategy.includes("token")) {
812
927
  return status(201).json({ ...user, token: auth2.id });
813
928
  }
@@ -820,7 +935,6 @@ async function finishLogin(ctx, input) {
820
935
  sameSite: "Lax"
821
936
  }).redirect(settings.redirect);
822
937
  }
823
- if (strategy.includes("jwt")) throw new Error("JWT auth not supported yet");
824
938
  if (strategy.includes("key")) throw new Error("Key auth not supported yet");
825
939
  throw new Error("Unknown auth type");
826
940
  }
@@ -909,7 +1023,7 @@ function clearState() {
909
1023
  // src/auth/providers/apple.ts
910
1024
  var AUTHORIZE = "https://appleid.apple.com/auth/authorize";
911
1025
  var TOKEN = "https://appleid.apple.com/auth/token";
912
- var b64url = (data) => {
1026
+ var b64url2 = (data) => {
913
1027
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
914
1028
  let bin = "";
915
1029
  for (const byte of bytes) bin += String.fromCharCode(byte);
@@ -931,7 +1045,7 @@ var clientSecret = async () => {
931
1045
  aud: "https://appleid.apple.com",
932
1046
  sub: env.APPLE_ID
933
1047
  };
934
- const data = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
1048
+ const data = `${b64url2(JSON.stringify(header))}.${b64url2(JSON.stringify(payload))}`;
935
1049
  const pem = String(env.APPLE_PRIVATE_KEY).replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
936
1050
  const der = Uint8Array.from(atob(pem), (c) => c.charCodeAt(0));
937
1051
  const key = await crypto.subtle.importKey(
@@ -946,7 +1060,7 @@ var clientSecret = async () => {
946
1060
  key,
947
1061
  new TextEncoder().encode(data)
948
1062
  );
949
- return `${data}.${b64url(new Uint8Array(sig))}`;
1063
+ return `${data}.${b64url2(new Uint8Array(sig))}`;
950
1064
  };
951
1065
  var login = (ctx) => {
952
1066
  const { state, cookie } = startState(ctx, true);
@@ -1282,6 +1396,19 @@ function parseAuthOptions(auth2, all) {
1282
1396
  throw new Error("Auth options needs a strategy");
1283
1397
  }
1284
1398
  const strategy = auth2.strategy;
1399
+ if (strategy === "key") {
1400
+ const key = auth2.key || env.AUTH_KEY;
1401
+ if (!key) {
1402
+ throw new Error("`key` auth needs the AUTH_KEY env var (or auth.key)");
1403
+ }
1404
+ return {
1405
+ strategy,
1406
+ providers: [],
1407
+ key,
1408
+ redirect: auth2.redirect || defaultRedirect,
1409
+ cleanUser: auth2.cleanUser || defaultCleanUser
1410
+ };
1411
+ }
1285
1412
  const list = Array.isArray(auth2.providers) ? auth2.providers : auth2.providers ? [auth2.providers] : [];
1286
1413
  if (!list.length) {
1287
1414
  throw new Error("Auth options needs a provider");
@@ -1529,6 +1656,11 @@ function config(options = {}) {
1529
1656
  if (options.auth || env2.AUTH) {
1530
1657
  settings.auth = parseAuthOptions(options.auth || env2.AUTH || null, options);
1531
1658
  }
1659
+ if (settings.auth?.strategy.includes("jwt") && settings.secret.startsWith("unsafe-")) {
1660
+ console.warn(
1661
+ "[server:auth] jwt strategy with no SECRET set: tokens are signed with a random per-process secret, so they break on restart and across instances. Set the SECRET environment variable (or the `secret` option)."
1662
+ );
1663
+ }
1532
1664
  if (options.openapi) {
1533
1665
  if (options.openapi === true) {
1534
1666
  settings.openapi = {};
@@ -1720,13 +1852,20 @@ async function parseResponse(out, ctx) {
1720
1852
  if (!ctx.options.session?.store) {
1721
1853
  throw ServerError_default.NO_STORE();
1722
1854
  }
1723
- if (!ctx.cookies.session) {
1855
+ let id = ctx.cookies.session;
1856
+ if (!id) {
1857
+ id = createId();
1724
1858
  out.headers.append(
1725
1859
  "set-cookie",
1726
- createCookies("session", { value: createId() })
1860
+ createCookies("session", {
1861
+ value: id,
1862
+ path: "/",
1863
+ httpOnly: true,
1864
+ secure: ctx.platform.production,
1865
+ sameSite: "Lax"
1866
+ })
1727
1867
  );
1728
1868
  }
1729
- const id = ctx.cookies.session;
1730
1869
  ctx.options.session.store.set(id, ctx.session);
1731
1870
  }
1732
1871
  if (ctx.options.cookies) {
@@ -1787,15 +1926,6 @@ function pathPattern(pattern, path2) {
1787
1926
  return null;
1788
1927
  }
1789
1928
 
1790
- // src/helpers/StatusError.ts
1791
- var StatusError = class extends Error {
1792
- status;
1793
- constructor(msg, status2 = 500) {
1794
- super(msg);
1795
- this.status = status2;
1796
- }
1797
- };
1798
-
1799
1929
  // src/helpers/validate.ts
1800
1930
  function validate(ctx, schema) {
1801
1931
  if (!schema || typeof schema !== "object") return;
@@ -2035,6 +2165,14 @@ async function verify(password, hash3) {
2035
2165
  });
2036
2166
  }
2037
2167
 
2168
+ // src/helpers/safeEqual.ts
2169
+ function safeEqual(a, b) {
2170
+ if (a.length !== b.length) return false;
2171
+ let diff = 0;
2172
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
2173
+ return diff === 0;
2174
+ }
2175
+
2038
2176
  // src/auth/findSessionId.ts
2039
2177
  var validateToken = (authorization) => {
2040
2178
  const [type2, id] = authorization.trim().split(" ");
@@ -2067,12 +2205,41 @@ function findSessionId(ctx) {
2067
2205
  }
2068
2206
 
2069
2207
  // src/auth/getUser.ts
2208
+ function getKeyUser(ctx) {
2209
+ const expected = ctx.options.auth.key;
2210
+ const header = ctx.headers.authorization;
2211
+ if (!header) return;
2212
+ const [type2, provided] = header.trim().split(" ");
2213
+ if (type2?.toLowerCase() !== "bearer" || !provided) {
2214
+ throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
2215
+ }
2216
+ if (!expected || !safeEqual(provided, expected)) {
2217
+ throw ServerError_default.AUTH_INVALID_TOKEN();
2218
+ }
2219
+ return { id: "key", strategy: "key", provider: "key" };
2220
+ }
2221
+ async function getAuthSession(ctx) {
2222
+ const strategy = ctx.options.auth.strategy;
2223
+ if (strategy.includes("jwt")) {
2224
+ const header = ctx.headers.authorization;
2225
+ if (!header) return;
2226
+ const [type2, token] = header.trim().split(" ");
2227
+ if (type2?.toLowerCase() !== "bearer" || !token) {
2228
+ throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
2229
+ }
2230
+ const payload = await verifyJwt(token, ctx.options.secret);
2231
+ if (!payload) throw ServerError_default.AUTH_INVALID_TOKEN();
2232
+ return payload;
2233
+ }
2234
+ const id = findSessionId(ctx);
2235
+ if (!id) return;
2236
+ return ctx.options.auth.session.get(id);
2237
+ }
2070
2238
  async function getUser(ctx) {
2071
2239
  if (!ctx.options.auth) return;
2072
2240
  const options = ctx.options.auth;
2073
- const sessionId = findSessionId(ctx);
2074
- if (!sessionId) return;
2075
- const auth2 = await options.session.get(sessionId);
2241
+ if (options.strategy === "key") return getKeyUser(ctx);
2242
+ const auth2 = await getAuthSession(ctx);
2076
2243
  if (!auth2) return;
2077
2244
  if (options.strategy !== auth2.strategy) {
2078
2245
  throw ServerError_default.AUTH_INVALID_STRATEGY({
@@ -2095,19 +2262,17 @@ async function getUser(ctx) {
2095
2262
 
2096
2263
  // src/auth/logout.ts
2097
2264
  async function logout(ctx) {
2098
- const session2 = findSessionId(ctx);
2099
2265
  const { strategy } = ctx.user;
2100
- await ctx.options.auth.session.del(session2);
2101
2266
  if (!strategy) throw new Error(`Invalid strategy "${strategy}"`);
2102
- if (strategy.includes("token")) {
2267
+ if (!strategy.includes("jwt")) {
2268
+ await ctx.options.auth.session.del(findSessionId(ctx));
2269
+ }
2270
+ if (strategy.includes("token") || strategy.includes("jwt")) {
2103
2271
  return { token: null };
2104
2272
  }
2105
2273
  if (strategy.includes("cookie")) {
2106
2274
  return cookies({ authentication: null }).redirect("/");
2107
2275
  }
2108
- if (strategy.includes("jwt")) {
2109
- throw new Error("JWT auth not supported yet");
2110
- }
2111
2276
  if (strategy.includes("key")) {
2112
2277
  throw new Error("Key auth not supported yet");
2113
2278
  }
@@ -2126,6 +2291,7 @@ function auth(app) {
2126
2291
  app.use(async function middle(ctx) {
2127
2292
  ctx.user = await getUser(ctx);
2128
2293
  });
2294
+ if (app.settings.auth.strategy === "key") return;
2129
2295
  app.post("/auth/logout", logout);
2130
2296
  const enabled = app.settings.auth.providers;
2131
2297
  for (const name of oauth2) {
@@ -2152,21 +2318,78 @@ function auth(app) {
2152
2318
  }
2153
2319
  }
2154
2320
 
2321
+ // src/helpers/parseRange.ts
2322
+ function parseRange(header, size) {
2323
+ if (!header) return null;
2324
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2325
+ if (!match) return null;
2326
+ const [, rawStart, rawEnd] = match;
2327
+ if (rawStart === "" && rawEnd === "") return null;
2328
+ let start;
2329
+ let end;
2330
+ if (rawStart === "") {
2331
+ const n = Number(rawEnd);
2332
+ if (n <= 0) return "unsatisfiable";
2333
+ start = Math.max(0, size - n);
2334
+ end = size - 1;
2335
+ } else {
2336
+ start = Number(rawStart);
2337
+ end = rawEnd === "" ? size - 1 : Number(rawEnd);
2338
+ }
2339
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
2340
+ if (size === 0 || start > end || start >= size) return "unsatisfiable";
2341
+ return { start, end: Math.min(end, size - 1) };
2342
+ }
2343
+
2155
2344
  // src/middle/assets.ts
2345
+ var CACHE_CONTROL = "public, max-age=3600";
2156
2346
  async function assets(ctx) {
2157
2347
  if (!ctx.options.public) return;
2158
2348
  if (ctx.method !== "get") return;
2159
2349
  if (ctx.url.pathname === "/") return;
2160
2350
  try {
2161
- const asset = ctx.options.public.file(ctx.url.pathname);
2162
- if (!await asset.exists()) return;
2163
- return type(ctx.url.pathname.split(".").pop()).send(asset.stream());
2351
+ const key = ctx.url.pathname.replace(/^\/+/, "");
2352
+ const file2 = ctx.options.public.file(key);
2353
+ const meta = file2.info ? await file2.info() : null;
2354
+ if (meta ? !meta.exists : !await file2.exists()) return;
2355
+ const ext2 = ctx.url.pathname.split(".").pop();
2356
+ const ctype = meta?.type || ext2;
2357
+ const headers2 = { "cache-control": CACHE_CONTROL };
2358
+ let tag;
2359
+ if (meta) {
2360
+ const stamp = meta.date ? meta.date.getTime() : 0;
2361
+ tag = `W/"${meta.size.toString(16)}-${stamp.toString(16)}"`;
2362
+ headers2.etag = tag;
2363
+ if (meta.date) headers2["last-modified"] = meta.date.toUTCString();
2364
+ }
2365
+ const canRange = !!(meta && file2.slice);
2366
+ if (canRange) headers2["accept-ranges"] = "bytes";
2367
+ if (tag && ctx.headers["if-none-match"] === tag) {
2368
+ return status(304).headers(headers2).send();
2369
+ }
2370
+ const rangeHeader = ctx.headers.range;
2371
+ const ifRange = ctx.headers["if-range"];
2372
+ if (meta && file2.slice && rangeHeader && (!ifRange || ifRange === tag)) {
2373
+ const range = parseRange(rangeHeader, meta.size);
2374
+ if (range === "unsatisfiable") {
2375
+ return status(416).headers({ ...headers2, "content-range": `bytes */${meta.size}` }).send();
2376
+ }
2377
+ if (range) {
2378
+ const { start, end } = range;
2379
+ return type(ctype).status(206).headers({
2380
+ ...headers2,
2381
+ "content-range": `bytes ${start}-${end}/${meta.size}`,
2382
+ "content-length": String(end - start + 1)
2383
+ }).send(file2.slice(start, end + 1).stream());
2384
+ }
2385
+ }
2386
+ return type(ctype).headers(headers2).send(file2.stream());
2164
2387
  } catch {
2165
2388
  }
2166
2389
  }
2167
2390
 
2168
2391
  // src/middle/favicon.ts
2169
- var CACHE_CONTROL = "public, max-age=86400";
2392
+ var CACHE_CONTROL2 = "public, max-age=86400";
2170
2393
  var ext = (name) => name.split(".").pop() || "ico";
2171
2394
  async function loadFavicon(fav) {
2172
2395
  try {
@@ -2185,7 +2408,7 @@ async function favicon(ctx) {
2185
2408
  }
2186
2409
  const entry = ctx.app.faviconCache;
2187
2410
  if (!entry) return 204;
2188
- const headers2 = { "cache-control": CACHE_CONTROL, etag: entry.etag };
2411
+ const headers2 = { "cache-control": CACHE_CONTROL2, etag: entry.etag };
2189
2412
  if (ctx.headers["if-none-match"] === entry.etag) {
2190
2413
  return status(304).headers(headers2).send();
2191
2414
  }
@@ -2693,12 +2916,16 @@ var Node = async (app) => {
2693
2916
  if ("error" in ctx) throw ctx.error;
2694
2917
  const out = await handleRequest(app, ctx);
2695
2918
  response.writeHead(out.status || 200, parseHeaders_default(out.headers));
2696
- if (out.body instanceof ReadableStream) {
2697
- await iterate(out.body, (chunk) => response.write(chunk));
2698
- } else {
2699
- response.write(out.body || "");
2919
+ try {
2920
+ if (out.body instanceof ReadableStream) {
2921
+ await iterate(out.body, (chunk) => response.write(chunk));
2922
+ } else {
2923
+ response.write(out.body || "");
2924
+ }
2925
+ response.end();
2926
+ } catch {
2927
+ if (!response.destroyed) response.destroy();
2700
2928
  }
2701
- response.end();
2702
2929
  }
2703
2930
  );
2704
2931
  await attachWebsocket(server2, app);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.34.2",
3
+ "version": "0.35.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",
@@ -51,7 +51,7 @@
51
51
  "@types/bun": "^1.3.0",
52
52
  "@types/jest": "^30.0.0",
53
53
  "@types/node": "^24.10.0",
54
- "bucket": "^0.2.0",
54
+ "bucket": "^0.4.0",
55
55
  "bun": "^1.3.13",
56
56
  "check-dts": "^0.8.2",
57
57
  "jest": "^29.7.0",