@server/next 0.38.0 → 0.40.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 (4) hide show
  1. package/index.d.ts +23 -14
  2. package/index.js +160 -245
  3. package/package.json +6 -11
  4. package/readme.md +28 -0
package/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as http from 'http';
2
+ export { default as kv } from 'polystore';
3
+ export { default as bucket } from 'bucket';
2
4
 
3
5
  type LimitOptions = {
4
6
  maxSize?: number | string;
@@ -128,6 +130,7 @@ type BasicValue = string | number | boolean | null;
128
130
  type SerializableValue = BasicValue | {
129
131
  [key: string]: SerializableValue;
130
132
  } | Array<SerializableValue>;
133
+ type StoreSource = KVStore | Map<string, any> | string | Record<string, any>;
131
134
  type KVStore = {
132
135
  name?: string;
133
136
  prefix: (prefix?: string) => KVStore;
@@ -140,7 +143,7 @@ type KVStore = {
140
143
  keys: () => Promise<string[]>;
141
144
  };
142
145
  type Provider = "email" | "github" | "google" | "microsoft" | "discord" | "facebook" | "apple";
143
- type Strategy = "cookie" | "jwt" | "token" | "key";
146
+ type Strategy = "cookie" | "jwt" | "token";
144
147
  type AuthSession = {
145
148
  id: string;
146
149
  provider: Provider;
@@ -153,22 +156,30 @@ type AuthUser<T = Record<string, any>> = T & {
153
156
  strategy: Strategy;
154
157
  email: string;
155
158
  };
156
- type AuthOption = `${Strategy}:${Provider}` | "key" | {
159
+ type ProfileUser = {
160
+ id: string | number;
161
+ email: string;
162
+ } & Record<string, any>;
163
+ type AuthOption = `${Strategy}:${Provider}` | {
157
164
  strategy: Strategy;
158
165
  providers?: Provider | Provider[];
159
- key?: string;
160
- session?: KVStore;
161
- store?: KVStore;
166
+ session?: StoreSource;
167
+ store?: StoreSource;
162
168
  redirect?: string;
163
- cleanUser?: <T = AuthUser>(user: T) => T | Promise<T>;
169
+ onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
170
+ onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
171
+ onUser?: <T = AuthUser>(user: T, ctx: Context) => T | Promise<T>;
172
+ onLogout?: (ctx: Context) => unknown;
164
173
  };
165
174
  type AuthSettings = {
166
175
  providers: Provider[];
167
176
  strategy: Strategy;
168
177
  store: KVStore;
169
178
  session: KVStore;
170
- key?: string;
171
- cleanUser: <T = AuthUser>(user: T) => T | Promise<T>;
179
+ onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
180
+ onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
181
+ onUser: <T = AuthUser>(user: T, ctx: Context) => T | Promise<T>;
182
+ onLogout?: (ctx: Context) => unknown;
172
183
  redirect: string;
173
184
  };
174
185
  type LogLevel = "info";
@@ -204,10 +215,9 @@ type Options = {
204
215
  secret?: string;
205
216
  public?: string | Bucket;
206
217
  uploads?: string | Bucket | UploadOptions;
207
- store?: KVStore;
208
- cookies?: KVStore;
209
- session?: KVStore | {
210
- store: KVStore;
218
+ store?: StoreSource;
219
+ session?: StoreSource | {
220
+ store: StoreSource;
211
221
  };
212
222
  cors?: CorsOptions;
213
223
  auth?: AuthOption;
@@ -228,7 +238,6 @@ type Settings = {
228
238
  bucket: Bucket;
229
239
  } & LimitOptions) | null;
230
240
  store?: KVStore;
231
- cookies?: KVStore;
232
241
  session?: {
233
242
  store: KVStore;
234
243
  };
@@ -500,4 +509,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
500
509
  }
501
510
  declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
502
511
 
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 };
512
+ 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 ProfileUser, type Provider, type Route, type RouteOptions, type RouterMethod, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type StoreSource, 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
@@ -68,6 +68,10 @@ ServerError_default.extend({
68
68
  status: 401,
69
69
  message: "Credentials do not correspond to a user"
70
70
  },
71
+ AUTH_INVALID_USER: {
72
+ status: 500,
73
+ message: "{callback} must return a user with an 'id' and an 'email'"
74
+ },
71
75
  LOGIN_NO_EMAIL: "The email is required to log in",
72
76
  LOGIN_INVALID_EMAIL: "The email you wrote is not correct",
73
77
  LOGIN_NO_PASSWORD: "The email is required to log in",
@@ -175,7 +179,7 @@ async function saveFileToBucket(originalName, data, bucket2, contentType) {
175
179
  };
176
180
  }
177
181
  function validateFile(originalName, data, contentType, limits) {
178
- const { maxSize, minSize, fileType } = limits;
182
+ const { maxSize, minSize, fileType: fileType2 } = limits;
179
183
  if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
180
184
  throw new Error(
181
185
  `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
@@ -186,15 +190,15 @@ function validateFile(originalName, data, contentType, limits) {
186
190
  `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
187
191
  );
188
192
  }
189
- if (fileType && fileType.length > 0) {
193
+ if (fileType2 && fileType2.length > 0) {
190
194
  const ext2 = getExt(originalName);
191
195
  const mime = contentType.toLowerCase();
192
- const allowed = fileType.some(
196
+ const allowed = fileType2.some(
193
197
  (t) => t.toLowerCase() === mime || t.toLowerCase() === ext2
194
198
  );
195
199
  if (!allowed) {
196
200
  throw new Error(
197
- `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
201
+ `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType2.join(", ")})`
198
202
  );
199
203
  }
200
204
  }
@@ -531,9 +535,9 @@ async function parseBody(input, contentType, dest, max = INF) {
531
535
  let limits;
532
536
  if (dest && "bucket" in dest) {
533
537
  bucket2 = dest.bucket;
534
- const { maxSize, minSize, fileType } = dest;
535
- if (maxSize != null || minSize != null || fileType != null) {
536
- limits = { maxSize, minSize, fileType };
538
+ const { maxSize, minSize, fileType: fileType2 } = dest;
539
+ if (maxSize != null || minSize != null || fileType2 != null) {
540
+ limits = { maxSize, minSize, fileType: fileType2 };
537
541
  }
538
542
  } else {
539
543
  bucket2 = dest;
@@ -661,8 +665,8 @@ function normalizeExpires(expires) {
661
665
  }
662
666
  function createCookies(key, val) {
663
667
  if (val.value === null) val.expires = EXPIRED;
664
- const { value, path: path2, expires, maxAge, httpOnly, secure, sameSite } = val;
665
- let str = `${key}=${encodeURIComponent(value ?? "")};Path=${path2 || "/"}`;
668
+ const { value, path, expires, maxAge, httpOnly, secure, sameSite } = val;
669
+ let str = `${key}=${encodeURIComponent(value ?? "")};Path=${path || "/"}`;
666
670
  if (typeof expires !== "undefined") str += `;Expires=${normalizeExpires(expires)}`;
667
671
  if (typeof maxAge === "number") str += `;Max-Age=${maxAge}`;
668
672
  if (httpOnly) str += ";HttpOnly";
@@ -725,6 +729,16 @@ function clientIp(headers2, opts = {}) {
725
729
  return normalize(remoteAddress);
726
730
  }
727
731
 
732
+ // src/helpers/store.ts
733
+ import kv from "polystore";
734
+ function toStore(source) {
735
+ const store = source;
736
+ if (store && typeof store.prefix === "function" && typeof store.get === "function" && typeof store.set === "function") {
737
+ return store;
738
+ }
739
+ return kv(source);
740
+ }
741
+
728
742
  // src/helpers/disposition.ts
729
743
  var encodeExt = (name) => encodeURIComponent(name).replace(
730
744
  /['()*]/g,
@@ -740,6 +754,14 @@ function disposition(name) {
740
754
  return `${value}; filename*=UTF-8''${encodeExt(clean)}`;
741
755
  }
742
756
 
757
+ // src/helpers/fileType.ts
758
+ function fileType(file2) {
759
+ if (file2.type) return file2.type;
760
+ const name = file2.path || file2.name || "";
761
+ const ext2 = name.split(".").pop()?.toLowerCase();
762
+ return ext2 ? mimes_default[ext2] : void 0;
763
+ }
764
+
743
765
  // src/helpers/isHtml.ts
744
766
  var TAG = /^\s*<[a-zA-Z!/]/;
745
767
  function isHtml(body) {
@@ -817,22 +839,22 @@ var Reply = class {
817
839
  }
818
840
  return this.send(JSON.stringify(body));
819
841
  }
820
- redirect(path2) {
821
- this.headers("location", path2);
842
+ redirect(path) {
843
+ this.headers("location", path);
822
844
  if (this.res.status == null) this.res.status = 302;
823
845
  return this.send();
824
846
  }
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());
847
+ async file(path) {
848
+ if (typeof path !== "string") {
849
+ if (!await path.exists()) return this.status(404).send();
850
+ return this.type(fileType(path)).send(path.stream());
829
851
  }
830
- if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path2)) return this.status(404).send();
852
+ if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path)) return this.status(404).send();
831
853
  try {
832
- const fs2 = await import("fs");
833
- const ext2 = path2.split(".").pop();
834
- await fs2.promises.access(path2);
835
- const stream = fs2.createReadStream(path2);
854
+ const fs = await import("fs");
855
+ const ext2 = path.split(".").pop();
856
+ await fs.promises.access(path);
857
+ const stream = fs.createReadStream(path);
836
858
  return this.type(ext2).send(stream);
837
859
  } catch (error) {
838
860
  if (error.code === "ENOENT" || error.code === "EISDIR") {
@@ -894,6 +916,13 @@ var json = (...args) => r().json(...args);
894
916
  var file = (...args) => r().file(...args);
895
917
  var redirect = (...args) => r().redirect(...args);
896
918
 
919
+ // src/auth/assertUser.ts
920
+ function assertUser(user, callback3) {
921
+ if (!user || typeof user !== "object" || user.id == null || !user.email) {
922
+ throw ServerError_default.AUTH_INVALID_USER({ callback: callback3 });
923
+ }
924
+ }
925
+
897
926
  // src/helpers/jwt.ts
898
927
  var enc = new TextEncoder();
899
928
  var dec = new TextDecoder();
@@ -964,7 +993,7 @@ async function verifyJwt(token, secret) {
964
993
  // src/auth/finishLogin.ts
965
994
  async function finishLogin(ctx, input) {
966
995
  const settings = ctx.options.auth;
967
- const { strategy, cleanUser } = settings;
996
+ const { strategy, onLogin, onUser } = settings;
968
997
  const key = String(input.key);
969
998
  const auth2 = {
970
999
  id: createId(),
@@ -974,22 +1003,28 @@ async function finishLogin(ctx, input) {
974
1003
  email: input.email,
975
1004
  time: (/* @__PURE__ */ new Date()).toISOString().replace(/\.[0-9]*/, "")
976
1005
  };
977
- let user = input.user;
978
- if (input.store !== false) {
979
- const existing = await settings.store.get(key);
980
- user = { ...existing ?? {}, ...input.user };
981
- }
982
- user = await cleanUser(user);
983
- if (input.store !== false) await settings.store.set(key, user);
1006
+ const loginUser = {
1007
+ ...input.user,
1008
+ provider: input.provider,
1009
+ strategy
1010
+ };
1011
+ const existingUser = await settings.store.get(key) ?? null;
1012
+ const user = onLogin ? await onLogin(loginUser, existingUser, ctx) : { ...existingUser ?? {}, ...loginUser };
1013
+ assertUser(user, "onLogin");
1014
+ await settings.store.set(key, user);
984
1015
  if (!strategy.includes("jwt")) {
985
1016
  await settings.session.set(auth2.id, auth2, { expires: "1w" });
986
1017
  }
987
1018
  if (strategy.includes("jwt")) {
988
1019
  const token = await signJwt(auth2, ctx.options.secret, 7 * 24 * 60 * 60);
989
- return status(201).json({ ...user, token });
1020
+ const exposed = await onUser(user, ctx);
1021
+ assertUser(exposed, "onUser");
1022
+ return status(201).json({ ...exposed, token });
990
1023
  }
991
1024
  if (strategy.includes("token")) {
992
- return status(201).json({ ...user, token: auth2.id });
1025
+ const exposed = await onUser(user, ctx);
1026
+ assertUser(exposed, "onUser");
1027
+ return status(201).json({ ...exposed, token: auth2.id });
993
1028
  }
994
1029
  if (strategy.includes("cookie")) {
995
1030
  return cookies("authentication", {
@@ -1000,7 +1035,6 @@ async function finishLogin(ctx, input) {
1000
1035
  sameSite: "Lax"
1001
1036
  }).redirect(settings.redirect);
1002
1037
  }
1003
- if (strategy.includes("key")) throw new Error("Key auth not supported yet");
1004
1038
  throw new Error("Unknown auth type");
1005
1039
  }
1006
1040
 
@@ -1110,11 +1144,15 @@ var callback = async (ctx) => {
1110
1144
  const parsed = JSON.parse(body.user).name;
1111
1145
  if (parsed) name = `${parsed.firstName} ${parsed.lastName}`.trim();
1112
1146
  }
1147
+ const raw = { ...claims, name };
1148
+ const { onProfile } = ctx.options.auth;
1149
+ const profile = onProfile ? await onProfile(raw, "apple") : { id: raw.sub, name: raw.name, email: raw.email };
1150
+ assertUser(profile, "onProfile");
1113
1151
  const res = await finishLogin(ctx, {
1114
1152
  provider: "apple",
1115
- key: claims.sub,
1116
- email: claims.email,
1117
- user: { id: claims.sub, name, email: claims.email }
1153
+ key: profile.id,
1154
+ email: profile.email,
1155
+ user: profile
1118
1156
  });
1119
1157
  res.headers.append("set-cookie", clearState());
1120
1158
  return res;
@@ -1163,17 +1201,15 @@ function oauthProvider(config2) {
1163
1201
  }
1164
1202
  });
1165
1203
  if (!profileRes.ok) throw new Error(`${config2.name}: profile fetch failed`);
1166
- const profile = config2.profile(await profileRes.json());
1204
+ const raw = await profileRes.json();
1205
+ const { onProfile } = ctx.options.auth;
1206
+ const profile = onProfile ? await onProfile(raw, config2.name) : config2.profile(raw);
1207
+ assertUser(profile, "onProfile");
1167
1208
  const res = await finishLogin(ctx, {
1168
1209
  provider: config2.name,
1169
1210
  key: profile.id,
1170
1211
  email: profile.email,
1171
- user: {
1172
- id: profile.id,
1173
- name: profile.name,
1174
- email: profile.email,
1175
- picture: profile.picture
1176
- }
1212
+ user: profile
1177
1213
  });
1178
1214
  res.headers.append("set-cookie", clearState());
1179
1215
  return res;
@@ -1219,8 +1255,7 @@ async function emailLogin(ctx) {
1219
1255
  provider: "email",
1220
1256
  key: user.email,
1221
1257
  email: user.email,
1222
- user,
1223
- store: false
1258
+ user
1224
1259
  });
1225
1260
  }
1226
1261
  async function emailRegister(ctx) {
@@ -1241,13 +1276,11 @@ async function emailRegister(ctx) {
1241
1276
  time,
1242
1277
  ...data
1243
1278
  };
1244
- await store.set(email, user);
1245
1279
  return finishLogin(ctx, {
1246
1280
  provider: "email",
1247
1281
  key: email,
1248
1282
  email,
1249
- user,
1250
- store: false
1283
+ user
1251
1284
  });
1252
1285
  }
1253
1286
  async function emailResetPassword() {
@@ -1301,8 +1334,8 @@ var oauth = async (code) => {
1301
1334
  code
1302
1335
  })
1303
1336
  });
1304
- return (path2) => {
1305
- return fch(`https://api.github.com${path2}`, {
1337
+ return (path) => {
1338
+ return fch(`https://api.github.com${path}`, {
1306
1339
  headers: { Authorization: `Bearer ${res.access_token}` }
1307
1340
  });
1308
1341
  };
@@ -1327,21 +1360,25 @@ var getUserProfile = async (code) => {
1327
1360
  const email = emails.sort((a) => a.primary ? -1 : 1)[0]?.email;
1328
1361
  return { ...profile, email };
1329
1362
  };
1363
+ var defaultProfile = (raw) => ({
1364
+ id: raw.id,
1365
+ name: raw.name,
1366
+ email: raw.email,
1367
+ picture: raw.avatar_url,
1368
+ location: raw.location,
1369
+ created: raw.created_at
1370
+ });
1330
1371
  var callback2 = async (ctx) => {
1331
1372
  checkState(ctx, ctx.url.query.state);
1332
- const profile = await getUserProfile(ctx.url.query.code);
1373
+ const raw = await getUserProfile(ctx.url.query.code);
1374
+ const { onProfile } = ctx.options.auth;
1375
+ const profile = onProfile ? await onProfile(raw, "github") : defaultProfile(raw);
1376
+ assertUser(profile, "onProfile");
1333
1377
  const res = await finishLogin(ctx, {
1334
1378
  provider: "github",
1335
1379
  key: profile.id,
1336
1380
  email: profile.email,
1337
- user: {
1338
- id: profile.id,
1339
- name: profile.name,
1340
- email: profile.email,
1341
- picture: profile.avatar_url,
1342
- location: profile.location,
1343
- created: profile.created_at
1344
- }
1381
+ user: profile
1345
1382
  });
1346
1383
  res.headers.append("set-cookie", clearState());
1347
1384
  return res;
@@ -1391,7 +1428,7 @@ var providers_default = {
1391
1428
 
1392
1429
  // src/auth/parseAuthOptions.ts
1393
1430
  var defaultRedirect = "/user";
1394
- function defaultCleanUser(fullUser) {
1431
+ function defaultOnUser(fullUser) {
1395
1432
  const { password: _password, ...user } = fullUser;
1396
1433
  return user;
1397
1434
  }
@@ -1406,19 +1443,6 @@ function parseAuthOptions(auth2, all) {
1406
1443
  throw new Error("Auth options needs a strategy");
1407
1444
  }
1408
1445
  const strategy = auth2.strategy;
1409
- if (strategy === "key") {
1410
- const key = auth2.key || env.AUTH_KEY;
1411
- if (!key) {
1412
- throw new Error("`key` auth needs the AUTH_KEY env var (or auth.key)");
1413
- }
1414
- return {
1415
- strategy,
1416
- providers: [],
1417
- key,
1418
- redirect: auth2.redirect || defaultRedirect,
1419
- cleanUser: auth2.cleanUser || defaultCleanUser
1420
- };
1421
- }
1422
1446
  const list = Array.isArray(auth2.providers) ? auth2.providers : auth2.providers ? [auth2.providers] : [];
1423
1447
  if (!list.length) {
1424
1448
  throw new Error("Auth options needs a provider");
@@ -1430,119 +1454,35 @@ function parseAuthOptions(auth2, all) {
1430
1454
  );
1431
1455
  }
1432
1456
  const redirect2 = auth2.redirect || defaultRedirect;
1433
- const cleanUser = auth2.cleanUser || defaultCleanUser;
1457
+ const { onProfile, onLogin, onLogout } = auth2;
1458
+ const onUser = auth2.onUser || defaultOnUser;
1434
1459
  if (!auth2.store && !all.store) {
1435
1460
  throw new Error("Need a userStore store for Auth");
1436
1461
  }
1437
1462
  if (!auth2.session && !all.store) {
1438
1463
  throw new Error("Need a sessionStore store for Auth");
1439
1464
  }
1440
- const store = auth2.store || all.store.prefix("user:");
1441
- const session2 = auth2.session || all.store.prefix("auth:");
1465
+ const store = all.store ? toStore(all.store) : null;
1466
+ const authStore = auth2.store ? toStore(auth2.store) : store.prefix("user:");
1467
+ const sessionStore = auth2.session ? toStore(auth2.session) : store.prefix("auth:");
1442
1468
  return {
1443
1469
  strategy,
1444
1470
  providers: list,
1445
1471
  redirect: redirect2,
1446
- cleanUser,
1447
- store,
1448
- session: session2
1472
+ onProfile,
1473
+ onLogin,
1474
+ onUser,
1475
+ onLogout,
1476
+ store: authStore,
1477
+ session: sessionStore
1449
1478
  };
1450
1479
  }
1451
1480
 
1452
1481
  // 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
- }
1482
+ import FileSystem from "bucket/fs";
1543
1483
  function bucket(root) {
1544
1484
  if (!root) return null;
1545
- if (typeof root === "string") return localBucket(root);
1485
+ if (typeof root === "string") return FileSystem(root);
1546
1486
  if (typeof root.file === "function") return root;
1547
1487
  throw new Error(
1548
1488
  "Invalid bucket: pass a directory path or a `bucket` instance (with .file())"
@@ -1638,14 +1578,14 @@ function createLogger(level) {
1638
1578
  const request = (ctx, res) => {
1639
1579
  if (!enabled) return;
1640
1580
  const method = ctx.method.toUpperCase();
1641
- const path2 = ctx.url.pathname;
1581
+ const path = ctx.url.pathname;
1642
1582
  const reqLen = Number(ctx.headers["content-length"]) || 0;
1643
1583
  const resLen = Number(res.headers.get("content-length")) || 0;
1644
1584
  const status2 = res.status;
1645
1585
  const text = STATUS_TEXT[status2] || "";
1646
1586
  const reqSize = reqLen ? ` ${formatBytes(reqLen)}` : "";
1647
1587
  const resSize = resLen ? ` ${formatBytes(resLen)}` : "";
1648
- let line = `${method} ${path2}${reqSize} \u2192 ${status2}${text ? ` ${text}` : ""}${resSize}`;
1588
+ let line = `${method} ${path}${reqSize} \u2192 ${status2}${text ? ` ${text}` : ""}${resSize}`;
1649
1589
  const location = res.headers.get("location");
1650
1590
  if (location) line += ` \u2192 ${location}`;
1651
1591
  message("api", line);
@@ -1772,22 +1712,22 @@ function config(options = {}) {
1772
1712
  if (!up) {
1773
1713
  settings.uploads = null;
1774
1714
  } else if (typeof up === "object" && "bucket" in up) {
1775
- const { bucket: bucket2, maxSize, minSize, fileType } = up;
1715
+ const { bucket: bucket2, maxSize, minSize, fileType: fileType2 } = up;
1776
1716
  if (maxSize != null) parseBytes(maxSize);
1777
1717
  if (minSize != null) parseBytes(minSize);
1778
- settings.uploads = { bucket: bucket(bucket2), maxSize, minSize, fileType };
1718
+ settings.uploads = { bucket: bucket(bucket2), maxSize, minSize, fileType: fileType2 };
1779
1719
  } else {
1780
1720
  settings.uploads = { bucket: bucket(up) };
1781
1721
  }
1782
1722
  const favicon2 = options.favicon || env2.FAVICON;
1783
1723
  if (favicon2) settings.favicon = favicon2;
1784
- settings.store = options.store ?? null;
1785
- settings.cookies = options.cookies ?? null;
1724
+ settings.store = options.store ? toStore(options.store) : null;
1786
1725
  if (options.session) {
1787
- settings.session = "store" in options.session ? options.session : { store: options.session };
1726
+ const store = typeof options.session === "object" && "store" in options.session ? options.session.store : options.session;
1727
+ settings.session = { store: toStore(store) };
1788
1728
  }
1789
- if (options.store && !options.session) {
1790
- settings.session = { store: options.store.prefix("session:") };
1729
+ if (settings.store && !options.session) {
1730
+ settings.session = { store: settings.store.prefix("session:") };
1791
1731
  }
1792
1732
  if (options.auth || env2.AUTH) {
1793
1733
  settings.auth = parseAuthOptions(options.auth || env2.AUTH || null, options);
@@ -1940,9 +1880,10 @@ async function parseResponse(out, ctx) {
1940
1880
  if (!await out.exists()) {
1941
1881
  out = new Response(null, { status: 404 });
1942
1882
  } else {
1883
+ const type2 = fileType(out);
1943
1884
  out = new Response(
1944
1885
  out.stream(),
1945
- out.type ? { headers: { "content-type": out.type } } : void 0
1886
+ type2 ? { headers: { "content-type": type2 } } : void 0
1946
1887
  );
1947
1888
  }
1948
1889
  }
@@ -2011,13 +1952,6 @@ async function parseResponse(out, ctx) {
2011
1952
  }
2012
1953
  ctx.options.session.store.set(id, ctx.session);
2013
1954
  }
2014
- if (ctx.options.cookies) {
2015
- if (Object.keys(ctx.res?.cookies || {}).length) {
2016
- for (const cookie of Object.values(ctx.res.cookies)) {
2017
- ctx.res.headers.append("set-cookie", cookie);
2018
- }
2019
- }
2020
- }
2021
1955
  if (ctx?.res?.headers) {
2022
1956
  for (const key in ctx.res.headers) {
2023
1957
  out.headers[key] = ctx.res.headers[key];
@@ -2027,14 +1961,14 @@ async function parseResponse(out, ctx) {
2027
1961
  }
2028
1962
 
2029
1963
  // src/pathPattern.ts
2030
- function pathPattern(pattern, path2) {
2031
- if (pattern === "*" && path2 === "/") return {};
1964
+ function pathPattern(pattern, path) {
1965
+ if (pattern === "*" && path === "/") return {};
2032
1966
  pattern = `/${pattern.replace(/^\//, "")}`;
2033
1967
  pattern = pattern.replace(/\/$/, "") || "/";
2034
- path2 = path2.replace(/\/$/, "") || "/";
2035
- if (pattern === path2) return {};
1968
+ path = path.replace(/\/$/, "") || "/";
1969
+ if (pattern === path) return {};
2036
1970
  const params = {};
2037
- const pathParts = path2.split("/").slice(1).map((u) => decodeURIComponent(u));
1971
+ const pathParts = path.split("/").slice(1).map((u) => decodeURIComponent(u));
2038
1972
  const pattParts = pattern.split("/").slice(1);
2039
1973
  let allSame = true;
2040
1974
  for (let i = 0; i < Math.max(pathParts.length, pattParts.length); i++) {
@@ -2093,7 +2027,7 @@ function validate(ctx, schema) {
2093
2027
  } catch (error) {
2094
2028
  if (error.name === "ZodError" || error.constructor.name === "ZodError") {
2095
2029
  const message = error.issues.map(
2096
- ({ path: path2, message: message2 }) => `[${base}.${path2.join(".")}]: ${message2}`
2030
+ ({ path, message: message2 }) => `[${base}.${path.join(".")}]: ${message2}`
2097
2031
  ).sort().join("\n");
2098
2032
  throw new StatusError(message, 422);
2099
2033
  }
@@ -2289,7 +2223,7 @@ async function verify(password, hash3) {
2289
2223
  const [, variant, , memory, passes, parallelism, saltB64, hashB64] = match;
2290
2224
  const nonce = Buffer.from(saltB64, "base64");
2291
2225
  const expected = Buffer.from(hashB64, "base64");
2292
- return new Promise((resolve2, reject) => {
2226
+ return new Promise((resolve, reject) => {
2293
2227
  crypto3.argon2(
2294
2228
  `argon2${variant}`,
2295
2229
  {
@@ -2303,23 +2237,15 @@ async function verify(password, hash3) {
2303
2237
  (err, derivedKey) => {
2304
2238
  if (err) return reject(err);
2305
2239
  if (derivedKey.length === expected.length && timingSafeEqual(derivedKey, expected)) {
2306
- resolve2(true);
2240
+ resolve(true);
2307
2241
  } else {
2308
- resolve2(false);
2242
+ resolve(false);
2309
2243
  }
2310
2244
  }
2311
2245
  );
2312
2246
  });
2313
2247
  }
2314
2248
 
2315
- // src/helpers/safeEqual.ts
2316
- function safeEqual(a, b) {
2317
- if (a.length !== b.length) return false;
2318
- let diff = 0;
2319
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
2320
- return diff === 0;
2321
- }
2322
-
2323
2249
  // src/auth/findSessionId.ts
2324
2250
  var validateToken = (authorization) => {
2325
2251
  const [type2, id] = authorization.trim().split(" ");
@@ -2352,19 +2278,6 @@ function findSessionId(ctx) {
2352
2278
  }
2353
2279
 
2354
2280
  // src/auth/getUser.ts
2355
- function getKeyUser(ctx) {
2356
- const expected = ctx.options.auth.key;
2357
- const header = ctx.headers.authorization;
2358
- if (!header) return;
2359
- const [type2, provided] = header.trim().split(" ");
2360
- if (type2?.toLowerCase() !== "bearer" || !provided) {
2361
- throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
2362
- }
2363
- if (!expected || !safeEqual(provided, expected)) {
2364
- throw ServerError_default.AUTH_INVALID_TOKEN();
2365
- }
2366
- return { id: "key", strategy: "key", provider: "key" };
2367
- }
2368
2281
  async function getAuthSession(ctx) {
2369
2282
  const strategy = ctx.options.auth.strategy;
2370
2283
  if (strategy.includes("jwt")) {
@@ -2385,7 +2298,6 @@ async function getAuthSession(ctx) {
2385
2298
  async function getUser(ctx) {
2386
2299
  if (!ctx.options.auth) return;
2387
2300
  const options = ctx.options.auth;
2388
- if (options.strategy === "key") return getKeyUser(ctx);
2389
2301
  const auth2 = await getAuthSession(ctx);
2390
2302
  if (!auth2) return;
2391
2303
  if (options.strategy !== auth2.strategy) {
@@ -2404,7 +2316,9 @@ async function getUser(ctx) {
2404
2316
  if (!user) throw ServerError_default.AUTH_NO_USER();
2405
2317
  user.strategy = auth2.strategy;
2406
2318
  user.provider = auth2.provider;
2407
- return ctx.options.auth.cleanUser(user);
2319
+ const exposed = await ctx.options.auth.onUser(user, ctx);
2320
+ assertUser(exposed, "onUser");
2321
+ return exposed;
2408
2322
  }
2409
2323
 
2410
2324
  // src/auth/logout.ts
@@ -2414,15 +2328,13 @@ async function logout(ctx) {
2414
2328
  if (!strategy.includes("jwt")) {
2415
2329
  await ctx.options.auth.session.del(findSessionId(ctx));
2416
2330
  }
2331
+ if (ctx.options.auth.onLogout) await ctx.options.auth.onLogout(ctx);
2417
2332
  if (strategy.includes("token") || strategy.includes("jwt")) {
2418
2333
  return { token: null };
2419
2334
  }
2420
2335
  if (strategy.includes("cookie")) {
2421
2336
  return cookies({ authentication: null }).redirect("/");
2422
2337
  }
2423
- if (strategy.includes("key")) {
2424
- throw new Error("Key auth not supported yet");
2425
- }
2426
2338
  throw new Error("Unknown auth type");
2427
2339
  }
2428
2340
 
@@ -2438,7 +2350,6 @@ function auth(app) {
2438
2350
  app.use(async function middle(ctx) {
2439
2351
  ctx.user = await getUser(ctx);
2440
2352
  });
2441
- if (app.settings.auth.strategy === "key") return;
2442
2353
  app.post("/auth/logout", logout);
2443
2354
  const enabled = app.settings.auth.providers;
2444
2355
  for (const name of oauth2) {
@@ -2500,8 +2411,8 @@ async function assets(ctx) {
2500
2411
  const info = file2.info?.bind(file2);
2501
2412
  const meta = info ? await info() : null;
2502
2413
  if (info ? !meta : !await file2.exists()) return;
2503
- const ext2 = ctx.url.pathname.split(".").pop();
2504
- const ctype = meta?.type || ext2;
2414
+ const ext2 = ctx.url.pathname.split(".").pop()?.toLowerCase();
2415
+ const ctype = ext2 && mimes_default[ext2] || meta?.type || ext2;
2505
2416
  const headers2 = { "cache-control": CACHE_CONTROL };
2506
2417
  let tag;
2507
2418
  if (meta) {
@@ -2564,7 +2475,7 @@ async function favicon(ctx) {
2564
2475
  }
2565
2476
 
2566
2477
  // src/middle/openapi.ts
2567
- import * as fsp2 from "fs/promises";
2478
+ import * as fsp from "fs/promises";
2568
2479
  var entities = {
2569
2480
  "&": "&amp;",
2570
2481
  "<": "&lt;",
@@ -2610,7 +2521,7 @@ function zodToSchema(schema) {
2610
2521
  }
2611
2522
  return { type: type2 };
2612
2523
  }
2613
- var pkgProm = fsp2.readFile("package.json", "utf-8").then((data) => JSON.parse(data)).catch(() => ({}));
2524
+ var pkgProm = fsp.readFile("package.json", "utf-8").then((data) => JSON.parse(data)).catch(() => ({}));
2614
2525
  var getTag = (name, fn) => {
2615
2526
  const found = fn.toString().split("\n").filter((l) => /\s+\/\/\s/.test(l)).map((l) => l.trim().replace("// ", "")).find((l) => l.startsWith(name));
2616
2527
  if (!found) return "";
@@ -2622,14 +2533,14 @@ var generateOpenApiPaths = (handlers) => {
2622
2533
  const paths = {};
2623
2534
  for (const [method, routes] of Object.entries(handlers)) {
2624
2535
  for (const route of routes) {
2625
- const path2 = route.path;
2536
+ const path = route.path;
2626
2537
  const fn = route.fns.find((p) => typeof p === "function");
2627
2538
  const meta = route.fns.find((p) => typeof p === "object");
2628
2539
  const config2 = getConfig(route.options);
2629
- if (typeof path2 !== "string" || path2 === "*" || path2 === "/docs" || !fn) {
2540
+ if (typeof path !== "string" || path === "*" || path === "/docs" || !fn) {
2630
2541
  continue;
2631
2542
  }
2632
- const normalizedPath = path2.replace(/\(\w+\)/gi, "").replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
2543
+ const normalizedPath = path.replace(/\(\w+\)/gi, "").replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
2633
2544
  if (!paths[normalizedPath]) {
2634
2545
  paths[normalizedPath] = {};
2635
2546
  }
@@ -2656,7 +2567,7 @@ var generateOpenApiPaths = (handlers) => {
2656
2567
  };
2657
2568
  }
2658
2569
  const parameters = [];
2659
- const matched = Array.from(path2.matchAll(/:[\w()]+/gi));
2570
+ const matched = Array.from(path.matchAll(/:[\w()]+/gi));
2660
2571
  matched.forEach((match) => {
2661
2572
  const [name, type2 = "string"] = match[0].slice(1).replace(/\)/, "").split("(");
2662
2573
  parameters.push({
@@ -2995,18 +2906,18 @@ async function createNode(req, app) {
2995
2906
  const cookies2 = parseCookies(headers2.cookie);
2996
2907
  const scheme = req.socket instanceof TLSSocket ? "https" : "http";
2997
2908
  const host = headers2.host || `localhost:${app.settings.port}`;
2998
- const path2 = (req.url || "/").replace(/\/$/, "") || "/";
2909
+ const path = (req.url || "/").replace(/\/$/, "") || "/";
2999
2910
  const baseUrl = `${scheme}://${host}`;
3000
- const url = new URL(path2, baseUrl);
2911
+ const url = new URL(path, baseUrl);
3001
2912
  define(
3002
2913
  url,
3003
2914
  "query",
3004
2915
  (url2) => Object.fromEntries(url2.searchParams.entries())
3005
2916
  );
3006
2917
  const source = {
3007
- getBuffer: () => new Promise((resolve2, reject) => {
2918
+ getBuffer: () => new Promise((resolve, reject) => {
3008
2919
  const chunks2 = [];
3009
- req.on("data", (chunk) => chunks2.push(chunk)).on("end", () => resolve2(Buffer.concat(chunks2))).on("error", reject);
2920
+ req.on("data", (chunk) => chunks2.push(chunk)).on("end", () => resolve(Buffer.concat(chunks2))).on("error", reject);
3010
2921
  }),
3011
2922
  getStream: () => toWeb(req)
3012
2923
  };
@@ -3158,9 +3069,9 @@ var Router = class _Router {
3158
3069
  // functions into a single flat `fns` list. A plain options object may sit
3159
3070
  // between the path and the handlers, and it's pulled out here.
3160
3071
  handle(method, pathOrFn, ...rest) {
3161
- let path2 = "*";
3072
+ let path = "*";
3162
3073
  if (typeof pathOrFn === "string") {
3163
- path2 = pathOrFn;
3074
+ path = pathOrFn;
3164
3075
  } else if (pathOrFn != null) {
3165
3076
  rest.unshift(pathOrFn);
3166
3077
  }
@@ -3170,7 +3081,7 @@ var Router = class _Router {
3170
3081
  }
3171
3082
  const base = method === "socket" ? [] : this.middleware;
3172
3083
  const fns = [...base, ...rest].filter((fn) => fn != null);
3173
- this.handlers[method].push({ path: path2, options, fns });
3084
+ this.handlers[method].push({ path, options, fns });
3174
3085
  return this.self();
3175
3086
  }
3176
3087
  socket(pathOrMid, optionsOrMid, ...middleware) {
@@ -3235,31 +3146,33 @@ function isSerializable(body) {
3235
3146
  }
3236
3147
  function ServerTest(app) {
3237
3148
  const port = app.settings.port;
3238
- const fetch2 = async (method, path2, options = {}) => {
3149
+ const fetch2 = async (method, path, options = {}) => {
3239
3150
  if (!options.headers) options.headers = {};
3240
3151
  if (isSerializable(options.body)) {
3241
3152
  options.headers["content-type"] = "application/json";
3242
3153
  options.body = JSON.stringify(options.body);
3243
3154
  }
3244
3155
  return await app.fetch(
3245
- new Request(`http://localhost:${port}${path2}`, {
3156
+ new Request(`http://localhost:${port}${path}`, {
3246
3157
  method,
3247
3158
  ...options
3248
3159
  })
3249
3160
  );
3250
3161
  };
3251
3162
  return {
3252
- get: (path2, options) => fetch2("get", path2, options),
3253
- head: (path2, options) => fetch2("head", path2, options),
3254
- post: (path2, body, options) => fetch2("post", path2, { body, ...options }),
3255
- put: (path2, body, options) => fetch2("put", path2, { body, ...options }),
3256
- patch: (path2, body, options) => fetch2("patch", path2, { body, ...options }),
3257
- delete: (path2, options) => fetch2("delete", path2, options),
3258
- options: (path2, options) => fetch2("options", path2, options)
3163
+ get: (path, options) => fetch2("get", path, options),
3164
+ head: (path, options) => fetch2("head", path, options),
3165
+ post: (path, body, options) => fetch2("post", path, { body, ...options }),
3166
+ put: (path, body, options) => fetch2("put", path, { body, ...options }),
3167
+ patch: (path, body, options) => fetch2("patch", path, { body, ...options }),
3168
+ delete: (path, options) => fetch2("delete", path, options),
3169
+ options: (path, options) => fetch2("options", path, options)
3259
3170
  };
3260
3171
  }
3261
3172
 
3262
3173
  // src/index.ts
3174
+ import { default as default2 } from "polystore";
3175
+ import { default as default3 } from "bucket";
3263
3176
  var Server = class extends Router {
3264
3177
  settings;
3265
3178
  platform;
@@ -3327,6 +3240,7 @@ function server(options) {
3327
3240
  export {
3328
3241
  Server,
3329
3242
  ServerError_default as ServerError,
3243
+ default3 as bucket,
3330
3244
  cache,
3331
3245
  cookies,
3332
3246
  server as default,
@@ -3334,6 +3248,7 @@ export {
3334
3248
  file,
3335
3249
  headers,
3336
3250
  json,
3251
+ default2 as kv,
3337
3252
  redirect,
3338
3253
  router,
3339
3254
  send,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.38.0",
3
+ "version": "0.40.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",
@@ -17,8 +17,7 @@
17
17
  "start": "bun test --watch",
18
18
  "lint": "npx tsc --noEmit && npx @biomejs/biome lint ./src --skip=lint/suspicious/noExplicitAny --skip=lint/style/noParameterAssign --skip=lint/suspicious/noConfusingVoidType --skip=lint/complexity/noBannedTypes",
19
19
  "test": "npm run test:bun && tsc --noEmit",
20
- "test:bun": "bun test",
21
- "test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
20
+ "test:bun": "bun test"
22
21
  },
23
22
  "main": "index.js",
24
23
  "type": "module",
@@ -64,20 +63,16 @@
64
63
  },
65
64
  "tutorials": "docs/tutorials"
66
65
  },
66
+ "dependencies": {
67
+ "bucket": "^0.7.1",
68
+ "polystore": "^0.23.3"
69
+ },
67
70
  "devDependencies": {
68
71
  "@types/bun": "^1.3.0",
69
- "@types/jest": "^30.0.0",
70
72
  "@types/node": "^24.10.0",
71
- "bucket": "^0.5.0",
72
73
  "bun": "^1.3.13",
73
74
  "check-dts": "^0.8.2",
74
- "jest": "^29.7.0",
75
- "polystore": "^0.21.1",
76
75
  "tsup": "^8.5.1",
77
76
  "typescript": "^6.0.2"
78
- },
79
- "jest": {
80
- "testEnvironment": "jest-environment-node",
81
- "transform": {}
82
77
  }
83
78
  }
package/readme.md CHANGED
@@ -1 +1,29 @@
1
1
  # Server JS [![@server/next](https://img.shields.io/npm/v/@server/next?label=@server/next&color=greenlime)](https://www.npmjs.com/package/@server/next) [![tests](https://github.com/franciscop/server-next/workflows/tests/badge.svg)](https://github.com/franciscop/server-next/actions)
2
+
3
+ A modern web server for Bun and Node, with routing, authentication, uploads, WebSockets and testing built in.
4
+
5
+ ```bash
6
+ npm install @server/next
7
+ ```
8
+
9
+ ```js
10
+ import server from '@server/next';
11
+
12
+ export default server({ store: new Map(), uploads: './uploads' })
13
+ .get('/', () => 'Hello world')
14
+ .get('/users/:id', (ctx) => db.users.find(ctx.url.params.id))
15
+ .post('/avatar', (ctx) => ctx.body.avatar.path);
16
+ ```
17
+
18
+ Key-value stores and file storage come included, so `store` takes a plain `Map` and `uploads` takes a folder path. For Redis, S3 and the rest, `kv` and `bucket` are exported too:
19
+
20
+ ```js
21
+ import server, { kv, bucket } from '@server/next';
22
+
23
+ const store = kv(createClient({ url }).connect());
24
+ const uploads = bucket.S3('my-bucket', { id, key });
25
+
26
+ export default server({ store, uploads });
27
+ ```
28
+
29
+ See the [full documentation](https://serverjs.io/documentation).