@server/next 0.44.1 → 0.45.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.
package/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as http from 'http';
2
+ import { Readable } from 'node:stream';
2
3
  export { default as kv } from 'polystore';
3
4
  export { default as bucket } from 'bucket';
4
5
 
@@ -9,6 +10,7 @@ type LimitOptions = {
9
10
  };
10
11
 
11
12
  type CookieOptions = string | string[] | Cookie | Cookie[] | null;
13
+ type SendBody = SerializableValue | JSX.Element | Uint8Array | ReadableStream | Readable | Response | Reply$1 | BucketFile | Promise<SendBody>;
12
14
  interface ResponseData {
13
15
  headers: Headers;
14
16
  status?: number;
@@ -22,10 +24,10 @@ declare class Reply$1 {
22
24
  headers(key: string | Record<string, string | string[]>, value?: string | string[]): this;
23
25
  cache(value: CacheOption): this;
24
26
  cookies(key: string | Record<string, CookieOptions>, value?: CookieOptions): this;
25
- json(body: unknown): Response;
26
- redirect(path: string): Response;
27
+ json(body: unknown): Promise<Response>;
28
+ redirect(path: string): Promise<Response>;
27
29
  file(path: string | BucketFile): Promise<Response>;
28
- send(body?: string | Buffer | ReadableStream | any): Response;
30
+ send(input?: SendBody): Promise<Response>;
29
31
  }
30
32
  type Params<K extends keyof Reply$1> = Reply$1[K] extends (...args: infer A) => any ? A : never;
31
33
  declare const status: (...args: Params<"status">) => Reply$1;
@@ -34,25 +36,23 @@ declare const type: (...args: Params<"type">) => Reply$1;
34
36
  declare const cache: (...args: Params<"cache">) => Reply$1;
35
37
  declare const download: (...args: Params<"download">) => Reply$1;
36
38
  declare const cookies: (...args: Params<"cookies">) => Reply$1;
37
- declare const send: (...args: Params<"send">) => Response;
38
- declare const json: (...args: Params<"json">) => Response;
39
+ declare const send: (...args: Params<"send">) => Promise<Response>;
40
+ declare const json: (...args: Params<"json">) => Promise<Response>;
39
41
  declare const file: (...args: Params<"file">) => Promise<Response>;
40
- declare const redirect: (...args: Params<"redirect">) => Response;
42
+ declare const redirect: (...args: Params<"redirect">) => Promise<Response>;
41
43
 
42
44
  type Reply = ReturnType<typeof status>;
43
45
  type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "socket";
44
46
  type ContextTypes = {
45
- session?: any;
46
47
  user?: any;
47
48
  params?: any;
48
49
  query?: any;
49
50
  body?: any;
50
51
  };
51
52
  type Field<C, K extends keyof ContextTypes, Fallback> = K extends keyof C ? SchemaOutput<C[K], C[K]> : Fallback;
52
- declare namespace JSX {
53
+ declare namespace JSX$1 {
53
54
  interface Element {
54
- type: any;
55
- props: any;
55
+ (): string;
56
56
  }
57
57
  interface IntrinsicElements {
58
58
  [elem: string]: any;
@@ -203,6 +203,7 @@ type AuthOption = `${Strategy}:${Provider}` | {
203
203
  strategy: Strategy;
204
204
  providers?: Provider | Provider[];
205
205
  users?: StoreSource;
206
+ sessions?: StoreSource;
206
207
  redirect?: string;
207
208
  onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
208
209
  onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
@@ -214,6 +215,7 @@ type AuthSettings = {
214
215
  providers: Provider[];
215
216
  strategy: Strategy;
216
217
  users: KVStore;
218
+ sessions: KVStore;
217
219
  onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
218
220
  onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
219
221
  onUser: <T = AuthUser>(user: T, ctx: Context) => T | Promise<T>;
@@ -256,7 +258,6 @@ type Options = {
256
258
  secret?: string;
257
259
  public?: string | Bucket;
258
260
  uploads?: string | Bucket | UploadOptions;
259
- sessions?: StoreSource;
260
261
  cors?: CorsOptions;
261
262
  auth?: AuthOption;
262
263
  openapi?: boolean | string | {
@@ -279,8 +280,6 @@ type Settings = {
279
280
  uploads?: ({
280
281
  bucket: Bucket;
281
282
  } & LimitOptions) | null;
282
- sessions: KVStore;
283
- sessionsDefault?: boolean;
284
283
  cors?: CorsSettings;
285
284
  auth?: AuthSettings;
286
285
  openapi?: {
@@ -339,7 +338,6 @@ type Context<C extends ContextTypes = {}> = {
339
338
  time?: Time;
340
339
  socket?: WebSocket;
341
340
  sockets?: WebSocket[];
342
- session: Field<C, "session", Record<string, any>>;
343
341
  user?: Field<C, "user", Record<string, any>>;
344
342
  init: number;
345
343
  app: Server;
@@ -351,7 +349,7 @@ type Context<C extends ContextTypes = {}> = {
351
349
  type InlineReply = Response | Reply | BucketFile | {
352
350
  body: string;
353
351
  headers?: Headers;
354
- } | SerializableValue | JSX.Element | Buffer | ReadableStream;
352
+ } | SerializableValue | JSX$1.Element | Buffer | ReadableStream;
355
353
  type Body = InlineReply;
356
354
  type Middleware<C extends ContextTypes = {}> = (ctx: Context<C>) => InlineReply | Promise<InlineReply> | void | Promise<void>;
357
355
 
@@ -373,7 +371,7 @@ declare class ServerError extends Error {
373
371
  declare const TypedServerError: typeof ServerError & ServerErrorConstructor;
374
372
 
375
373
  declare global {
376
- var env: Record<string, any>;
374
+ var env: Record<string, string | undefined>;
377
375
  }
378
376
 
379
377
  type Fn<C extends ContextTypes> = (ctx: Context<C>) => ReturnType<Middleware>;
@@ -440,7 +438,7 @@ declare class ValidationError extends StatusError {
440
438
  declare class Server<C extends ContextTypes = {}> extends Router<C> {
441
439
  settings: Settings;
442
440
  platform: Platform;
443
- sockets: any[];
441
+ sockets: WebSocket[];
444
442
  websocket: any;
445
443
  port?: number;
446
444
  constructor(options?: Options);
package/index.js CHANGED
@@ -49,8 +49,6 @@ ServerError_default.extend({
49
49
  status: 400,
50
50
  message: "Missing the OAuth 'code' in the request body"
51
51
  },
52
- SESSION_JWT: "The `jwt` strategy is stateless, so there is no `ctx.session` (tried '{key}'). Use the `token` strategy for server-side sessions, or `cookie` for browsers",
53
- SESSION_GUEST: "No `ctx.session` for this request (tried '{key}'): the `token` strategy carries the session in the Authorization header, and this request has none. Sign in first, or use the `cookie` strategy for guest sessions",
54
52
  AUTH_INVALID_HEADER: {
55
53
  status: 401,
56
54
  message: "Invalid authorization header {type}, must send 'Bearer {TOKEN}' (with space)"
@@ -799,7 +797,7 @@ function isReadableStream(obj) {
799
797
 
800
798
  // src/reply.ts
801
799
  var EXPIRED2 = (/* @__PURE__ */ new Date(0)).toUTCString();
802
- var Reply = class {
800
+ var Reply = class _Reply {
803
801
  res;
804
802
  constructor() {
805
803
  this.res = {
@@ -870,10 +868,12 @@ var Reply = class {
870
868
  }
871
869
  async file(path) {
872
870
  if (typeof path !== "string") {
873
- if (!await path.exists()) return this.status(404).send();
871
+ if (!await path.exists()) return new Response(null, { status: 404 });
874
872
  return this.type(fileType(path)).send(path.stream());
875
873
  }
876
- if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path)) return this.status(404).send();
874
+ if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path)) {
875
+ return new Response(null, { status: 404 });
876
+ }
877
877
  try {
878
878
  const fs = await import("fs");
879
879
  const ext = path.split(".").pop();
@@ -882,23 +882,55 @@ var Reply = class {
882
882
  return this.type(ext).send(stream);
883
883
  } catch (error) {
884
884
  if (error.code === "ENOENT" || error.code === "EISDIR") {
885
- return this.status(404).send();
885
+ return new Response(null, { status: 404 });
886
886
  }
887
887
  throw error;
888
888
  }
889
889
  }
890
- send(body = "") {
890
+ // Accepts everything a route can return, so `send(x)` and `return x` agree.
891
+ // Async because a bucket file has to be read before its status is known;
892
+ // routes await whatever they return, so this is invisible in normal use.
893
+ async send(input = "") {
891
894
  const { status: status2 = 200, headers: headers2 } = this.res;
895
+ let body = input;
892
896
  if (status2 === 101 || status2 === 204 || status2 === 205 || status2 === 304) {
893
897
  return new Response(null, { status: status2, headers: headers2 });
894
898
  }
895
899
  if (body === null) body = "";
900
+ if (typeof body?.then === "function") body = await body;
896
901
  if (typeof body === "function") body = body();
897
902
  if (typeof body?.then === "function") {
898
903
  throw new Error(
899
- "send() received a promise, likely an async component. Await it first, or return it from the route, which resolves it for you."
904
+ "Cannot render an async component: components must be synchronous. Await the data before rendering and pass it in as props."
900
905
  );
901
906
  }
907
+ if (body instanceof _Reply) body = await body.send();
908
+ if (body instanceof Response) {
909
+ const merged = new Headers(body.headers);
910
+ for (const [key, value] of headers2) {
911
+ if (key === "set-cookie") continue;
912
+ merged.set(key, value);
913
+ }
914
+ for (const cookie of headers2.getSetCookie?.() ?? []) {
915
+ merged.append("set-cookie", cookie);
916
+ }
917
+ if (body.url && /^(br|gzip)$/.test(merged.get("content-encoding") || "")) {
918
+ merged.delete("content-encoding");
919
+ }
920
+ return new Response(body.body, {
921
+ status: this.res.status ?? body.status,
922
+ headers: merged
923
+ });
924
+ }
925
+ if (body && typeof body.stream === "function" && typeof body.bytes === "function" && typeof body.exists === "function" && typeof body.name === "string") {
926
+ return this.file(body);
927
+ }
928
+ if (body instanceof Blob) {
929
+ if (!headers2.get("content-type") && body.type) {
930
+ headers2.set("content-type", body.type);
931
+ }
932
+ return new Response(body, { status: status2, headers: headers2 });
933
+ }
902
934
  if (typeof body === "string") {
903
935
  if (!headers2.get("content-type")) {
904
936
  headers2.set("content-type", isHtml(body) ? mimes_default.html : mimes_default.text);
@@ -924,6 +956,12 @@ var Reply = class {
924
956
  if (isReadableStream(body)) {
925
957
  return new Response(toWeb(body), { status: status2, headers: headers2 });
926
958
  }
959
+ if (!Array.isArray(body) && body?.[Symbol.iterator]) {
960
+ return new Response(iteratorToReadable(body), { status: status2, headers: headers2 });
961
+ }
962
+ if (body?.[Symbol.asyncIterator]) {
963
+ return new Response(iteratorAsyncToReadable(body), { status: status2, headers: headers2 });
964
+ }
927
965
  if (!headers2.get("content-type")) {
928
966
  headers2.set("content-type", "application/json");
929
967
  }
@@ -1039,48 +1077,11 @@ function findSessionId(ctx) {
1039
1077
  return ctx.cookies.session || void 0;
1040
1078
  }
1041
1079
 
1042
- // src/middle/session.ts
1043
- var loaded = /* @__PURE__ */ new WeakMap();
1044
- function noSession(error) {
1045
- const target = {};
1046
- return new Proxy(target, {
1047
- get(target2, key) {
1048
- if (typeof key === "symbol" || key === "then") return target2[key];
1049
- throw error(String(key));
1050
- },
1051
- set(target2, key, value) {
1052
- if (typeof key === "symbol") {
1053
- target2[key] = value;
1054
- return true;
1055
- }
1056
- throw error(String(key));
1057
- }
1058
- });
1059
- }
1060
- async function session(ctx) {
1061
- const strategy = ctx.options.auth?.strategy;
1062
- if (strategy?.includes("jwt")) {
1063
- ctx.session = noSession((key) => ServerError_default.SESSION_JWT({ key }));
1064
- return;
1065
- }
1066
- const id = findSessionId(ctx);
1067
- if (!id && strategy?.includes("token")) {
1068
- ctx.session = noSession((key) => ServerError_default.SESSION_GUEST({ key }));
1069
- return;
1070
- }
1071
- ctx.session = id && await ctx.options.sessions.get(id) || {};
1072
- loaded.set(ctx, { id, data: JSON.stringify(ctx.session) });
1073
- }
1074
-
1075
1080
  // src/auth/finishLogin.ts
1076
1081
  async function finishLogin(ctx, input, opts = {}) {
1077
1082
  const settings = ctx.options.auth;
1078
1083
  const { strategy, onLogin, onUser, onToken } = settings;
1079
1084
  const key = String(input.key);
1080
- if (!strategy.includes("jwt") && !loaded.has(ctx)) {
1081
- ctx.session = {};
1082
- loaded.set(ctx, { id: void 0, data: "{}" });
1083
- }
1084
1085
  const auth2 = {
1085
1086
  user: key,
1086
1087
  provider: input.provider,
@@ -1106,12 +1107,10 @@ async function finishLogin(ctx, input, opts = {}) {
1106
1107
  assertUser(exposed, "onUser");
1107
1108
  return status(201).json({ ...exposed, token });
1108
1109
  }
1109
- const prev = loaded.get(ctx);
1110
- if (prev?.id) await ctx.options.sessions.del(prev.id);
1110
+ const prev = findSessionId(ctx);
1111
+ if (prev) await settings.sessions.del(prev);
1111
1112
  const id = createId();
1112
- Object.assign(ctx.session, auth2);
1113
- await ctx.options.sessions.set(id, ctx.session);
1114
- loaded.set(ctx, { id, data: JSON.stringify(ctx.session) });
1113
+ await settings.sessions.set(id, auth2);
1115
1114
  if (strategy.includes("token")) {
1116
1115
  const exposed = await onUser(user, ctx);
1117
1116
  assertUser(exposed, "onUser");
@@ -1631,6 +1630,7 @@ function parseAuthOptions(auth2) {
1631
1630
  const onUser = auth2.onUser || defaultOnUser;
1632
1631
  const onToken = auth2.onToken || defaultOnUser;
1633
1632
  const users = auth2.users ? toStore(auth2.users) : null;
1633
+ const sessions = auth2.sessions ? toStoreExpiring(auth2.sessions, "1w") : null;
1634
1634
  return {
1635
1635
  strategy,
1636
1636
  providers: list,
@@ -1640,7 +1640,8 @@ function parseAuthOptions(auth2) {
1640
1640
  onUser,
1641
1641
  onToken,
1642
1642
  onLogout,
1643
- users
1643
+ users,
1644
+ sessions
1644
1645
  };
1645
1646
  }
1646
1647
 
@@ -1830,7 +1831,8 @@ function config(options = {}) {
1830
1831
  const level = raw === true ? "info" : raw === false ? void 0 : raw;
1831
1832
  const log = createLogger(level);
1832
1833
  const settings = {
1833
- port: options.port || env2.PORT || 3e3,
1834
+ // `env.PORT` is a string, so coerce it: `settings.port` is a number
1835
+ port: options.port || Number(env2.PORT) || 3e3,
1834
1836
  secret: options.secret || env2.SECRET || `unsafe-${createId()}`,
1835
1837
  log,
1836
1838
  // How request bodies are read: parsed into ctx.body by default; `raw` keeps
@@ -1838,11 +1840,7 @@ function config(options = {}) {
1838
1840
  parser: options.parser ?? "parse",
1839
1841
  // Secure-by-default response headers + trustProxy for ctx.ip. `false` turns
1840
1842
  // the added headers off; see resolveSecurity for the defaults.
1841
- security: resolveSecurity(options.security),
1842
- // Sessions: one record per device, exposed as ctx.session. Anything
1843
- // polystore accepts works; raw sources (a Map, a Redis client) get a 1w
1844
- // expiry, a built store is honored as-is, prefix and expiry included.
1845
- sessions: toStoreExpiring(options.sessions ?? /* @__PURE__ */ new Map(), "1w")
1843
+ security: resolveSecurity(options.security)
1846
1844
  };
1847
1845
  if (options.cache !== void 0) settings.cache = options.cache;
1848
1846
  options.cors = options.cors || env2.CORS || null;
@@ -1885,10 +1883,10 @@ function config(options = {}) {
1885
1883
  settings.public = publicDir ? bucket(publicDir) : null;
1886
1884
  settings.uploads = resolveUploads(options.uploads);
1887
1885
  const production = env2.NODE_ENV === "production";
1888
- const defaulted = options.sessions == null;
1889
- settings.sessionsDefault = defaulted;
1890
1886
  if (options.auth || env2.AUTH) {
1891
- settings.auth = parseAuthOptions(options.auth || env2.AUTH || null);
1887
+ settings.auth = parseAuthOptions(
1888
+ options.auth || env2.AUTH || null
1889
+ );
1892
1890
  }
1893
1891
  if (settings.auth) {
1894
1892
  if (!settings.auth.users) {
@@ -1899,10 +1897,13 @@ function config(options = {}) {
1899
1897
  }
1900
1898
  settings.auth.users = toStore(/* @__PURE__ */ new Map());
1901
1899
  }
1902
- if (production && defaulted && !settings.auth.strategy.includes("jwt")) {
1903
- throw new Error(
1904
- "Auth in production needs a persistent `sessions` store, like sessions: kv(redis).prefix('session:')."
1905
- );
1900
+ if (!settings.auth.sessions && !settings.auth.strategy.includes("jwt")) {
1901
+ if (production) {
1902
+ throw new Error(
1903
+ "Auth in production needs a persistent `sessions` store, like auth: { ..., sessions: kv(redis).prefix('session:') }."
1904
+ );
1905
+ }
1906
+ settings.auth.sessions = toStoreExpiring(/* @__PURE__ */ new Map(), "1w");
1906
1907
  }
1907
1908
  }
1908
1909
  if (settings.auth?.strategy.includes("jwt") && settings.secret.startsWith("unsafe-")) {
@@ -1928,7 +1929,6 @@ function config(options = {}) {
1928
1929
  }
1929
1930
  if (settings.public) log.message("public", loc(options.public));
1930
1931
  if (settings.uploads) log.message("uploads", loc(options.uploads));
1931
- if (options.sessions) log.message("sessions", "enabled");
1932
1932
  if (settings.cors) {
1933
1933
  const origin = settings.cors.origin === true ? "*" : String(settings.cors.origin);
1934
1934
  log.message("cors", origin);
@@ -2038,74 +2038,17 @@ function getMachine() {
2038
2038
  }
2039
2039
 
2040
2040
  // src/parseResponse.ts
2041
- var warned = false;
2042
- var warnDefault = () => {
2043
- if (warned) return;
2044
- warned = true;
2045
- console.warn(
2046
- "[server:sessions] Using the default in-memory session store in production: sessions are lost on restart and not shared across instances. Configure one with sessions: kv(redis).prefix('session:')."
2047
- );
2048
- };
2049
2041
  async function parseResponse(out, ctx) {
2050
2042
  if (!out && typeof out !== "string") return null;
2051
2043
  if (typeof out === "function") {
2052
2044
  out = await out(ctx);
2053
- }
2054
- if (out && typeof out.send === "function" && out.res?.headers instanceof Headers) {
2055
- out = out.send();
2056
- }
2057
- if (out instanceof Blob) {
2058
- out = new Response(out, { headers: { "Content-Type": out.type } });
2059
- }
2060
- if (out && typeof out.stream === "function" && typeof out.bytes === "function" && typeof out.exists === "function" && typeof out.name === "string") {
2061
- if (!await out.exists()) {
2062
- out = new Response(null, { status: 404 });
2063
- } else {
2064
- const type2 = fileType(out);
2065
- out = new Response(
2066
- out.stream(),
2067
- type2 ? { headers: { "content-type": type2 } } : void 0
2068
- );
2069
- }
2070
- }
2071
- if (out instanceof ReadableStream) {
2072
- out = new Response(out);
2073
- }
2074
- if (out instanceof Uint8Array) {
2075
- out = new Response(out);
2045
+ if (!out && typeof out !== "string") return null;
2076
2046
  }
2077
2047
  if (typeof out === "number") {
2078
- out = new Response(void 0, { status: out });
2048
+ out = new Response(null, { status: out });
2079
2049
  }
2080
- if (typeof out === "string") {
2081
- const type2 = isHtml(out) ? mimes_default.html : mimes_default.text;
2082
- out = new Response(out, {
2083
- headers: {
2084
- "content-type": type2,
2085
- "content-length": String(Buffer.byteLength(out))
2086
- }
2087
- });
2088
- }
2089
- if (out?.constructor === Object || Array.isArray(out)) {
2090
- out = json(out);
2091
- }
2092
- if (out[Symbol.iterator]) {
2093
- out = new Response(iteratorToReadable(out));
2094
- }
2095
- if (out[Symbol.asyncIterator] && !(out instanceof Response)) {
2096
- out = new Response(iteratorAsyncToReadable(out));
2097
- }
2098
- if (out instanceof Response && out.url && out.body) {
2099
- out = new Response(out.body, {
2100
- status: out.status,
2101
- headers: out.headers
2102
- });
2103
- if (/^(br|gzip)$/.test(out.headers.get("content-encoding") || "")) {
2104
- out.headers.delete("content-encoding");
2105
- }
2106
- }
2107
- if (!(out instanceof Response)) {
2108
- throw new Error(`Invalid response type ${out}`);
2050
+ if (!(out instanceof Response) || out.url) {
2051
+ out = await send(out);
2109
2052
  }
2110
2053
  applyCors(out, ctx);
2111
2054
  applySecurity(out, ctx);
@@ -2113,27 +2056,6 @@ async function parseResponse(out, ctx) {
2113
2056
  if (ctx.time?.times?.length > 1) {
2114
2057
  out.headers.set("Server-Timing", ctx.time.headers());
2115
2058
  }
2116
- const prev = loaded.get(ctx);
2117
- if (prev && JSON.stringify(ctx.session ?? {}) !== prev.data) {
2118
- if (ctx.options.sessionsDefault && ctx.platform.production) {
2119
- warnDefault();
2120
- }
2121
- let id = prev.id;
2122
- if (!id) {
2123
- id = createId();
2124
- out.headers.append(
2125
- "set-cookie",
2126
- createCookies("session", {
2127
- value: id,
2128
- path: "/",
2129
- httpOnly: true,
2130
- secure: ctx.platform.production,
2131
- sameSite: "Lax"
2132
- })
2133
- );
2134
- }
2135
- ctx.options.sessions.set(id, ctx.session);
2136
- }
2137
2059
  return out;
2138
2060
  }
2139
2061
 
@@ -2472,14 +2394,10 @@ async function getJwtUser(ctx) {
2472
2394
  return exposed;
2473
2395
  }
2474
2396
  async function getAuthSession(ctx) {
2475
- if (loaded.has(ctx)) {
2476
- const session3 = ctx.session;
2477
- return session3?.user ? session3 : void 0;
2478
- }
2479
2397
  const id = findSessionId(ctx);
2480
2398
  if (!id) return;
2481
- const session2 = await ctx.options.sessions.get(id);
2482
- return session2?.user ? session2 : void 0;
2399
+ const session = await ctx.options.auth.sessions.get(id);
2400
+ return session?.user ? session : void 0;
2483
2401
  }
2484
2402
  async function getUser(ctx) {
2485
2403
  if (!ctx.options.auth) return;
@@ -2504,10 +2422,8 @@ async function getUser(ctx) {
2504
2422
  async function logout(ctx) {
2505
2423
  const { strategy } = ctx.options.auth;
2506
2424
  if (!strategy.includes("jwt")) {
2507
- const prev = loaded.get(ctx);
2508
- if (prev?.id) await ctx.options.sessions.del(prev.id);
2509
- ctx.session = {};
2510
- loaded.set(ctx, { id: void 0, data: "{}" });
2425
+ const prev = findSessionId(ctx);
2426
+ if (prev) await ctx.options.auth.sessions.del(prev);
2511
2427
  }
2512
2428
  if (ctx.options.auth.onLogout) await ctx.options.auth.onLogout(ctx);
2513
2429
  if (strategy.includes("token") || strategy.includes("jwt")) {
@@ -3034,7 +2950,6 @@ async function createNode(req, app, signal = new AbortController().signal) {
3034
2950
  headers: headers2,
3035
2951
  cookies: cookies2,
3036
2952
  signal,
3037
- session: {},
3038
2953
  init,
3039
2954
  app,
3040
2955
  ip: clientIp(headers2, {
@@ -3075,7 +2990,6 @@ async function createWinter(req, app, server2) {
3075
2990
  headers: headers2,
3076
2991
  cookies: cookies2,
3077
2992
  signal: req.signal,
3078
- session: {},
3079
2993
  init,
3080
2994
  app,
3081
2995
  ip: clientIp(headers2, {
@@ -3326,7 +3240,6 @@ var Server = class extends Router {
3326
3240
  app.use(timer);
3327
3241
  if (this.settings.cors) app.use(preflight);
3328
3242
  app.use(assets);
3329
- app.use(session);
3330
3243
  if (this.settings.auth) {
3331
3244
  auth(app);
3332
3245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.44.1",
3
+ "version": "0.45.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",
@@ -25,7 +25,8 @@
25
25
  "types": "index.d.ts",
26
26
  "files": [
27
27
  "index.d.ts",
28
- "src/jsx/"
28
+ "src/jsx/*.js",
29
+ "src/jsx/jsx-runtime.d.ts"
29
30
  ],
30
31
  "exports": {
31
32
  ".": "./index.js",
@@ -45,6 +46,7 @@
45
46
  "menu": {
46
47
  "Documentation": "/documentation",
47
48
  "Tutorials": "/tutorials",
49
+ "Donate": "https://www.paypal.com/paypalme/franciscopresencia/19",
48
50
  "Github": "https://github.com/franciscop/server-next"
49
51
  },
50
52
  "documentation": {
package/readme.md CHANGED
@@ -15,16 +15,24 @@ export default server({ uploads: './uploads' })
15
15
  .post('/avatar', (ctx) => ctx.body.avatar.path);
16
16
  ```
17
17
 
18
- Key-value stores and file storage come included, so `sessions` work out of the box and `uploads` takes a folder path. For Redis, S3 and the rest, pass the client straight in:
18
+ Key-value stores and file storage come included, so logins work out of the box and `uploads` takes a folder path. For Redis, S3 and the rest, pass the client straight in:
19
19
 
20
20
  ```js
21
- import server, { bucket } from '@server/next';
21
+ import server, { bucket, kv } from '@server/next';
22
22
  import { createClient } from 'redis';
23
23
 
24
- const sessions = createClient({ url });
24
+ const redis = kv(createClient({ url }));
25
25
  const uploads = bucket.S3('my-bucket', { id, key });
26
26
 
27
- export default server({ sessions, uploads });
27
+ export default server({
28
+ uploads,
29
+ auth: {
30
+ strategy: 'cookie',
31
+ providers: ['github'],
32
+ users: redis.prefix('user:'),
33
+ sessions: redis.prefix('session:'),
34
+ },
35
+ });
28
36
  ```
29
37
 
30
38
  See the [full documentation](https://serverjs.io/documentation).
@@ -1,12 +1,31 @@
1
- export declare function jsx(type: any, props: any, key?: any): any;
2
- export declare function jsxs(type: any, props: any, key?: any): any;
3
- export declare function jsxDEV(type: any, props: any, key?: any): any;
4
- export declare const Fragment: unique symbol;
1
+ // What a JSX tag can be: an intrinsic name, a Fragment, a function component,
2
+ // or an object exposing `render` (the shape forwardRef-style components take)
3
+ type Tag =
4
+ | string
5
+ | symbol
6
+ | ((props: any) => unknown)
7
+ | { render: (props: any, ref: unknown) => unknown };
8
+
9
+ export declare function jsx(
10
+ type: Tag,
11
+ props?: any,
12
+ key?: string | number,
13
+ ): JSX.Element;
14
+ export declare function jsxs(
15
+ type: Tag,
16
+ props?: any,
17
+ key?: string | number,
18
+ ): JSX.Element;
19
+ export declare function jsxDEV(
20
+ type: Tag,
21
+ props?: any,
22
+ key?: string | number,
23
+ ): JSX.Element;
24
+ export declare const Fragment: symbol;
5
25
 
6
26
  export declare namespace JSX {
7
27
  interface Element {
8
- type: any;
9
- props: any;
28
+ (): string;
10
29
  }
11
30
  interface IntrinsicElements {
12
31
  [elem: string]: any;
@@ -16,8 +35,7 @@ export declare namespace JSX {
16
35
  declare global {
17
36
  namespace JSX {
18
37
  interface Element {
19
- type: any;
20
- props: any;
38
+ (): string;
21
39
  }
22
40
  interface IntrinsicElements {
23
41
  [elem: string]: any;
@@ -21,8 +21,6 @@ const SVG_TAGS = new Set([
21
21
  "defs",
22
22
  ]);
23
23
 
24
- const REACT_FRAGMENT_TYPE = Symbol.for("react.fragment");
25
-
26
24
  const ALT_NAMES = {
27
25
  classname: "class",
28
26
  htmlfor: "for",
@@ -100,8 +98,9 @@ const renderChild = (child) => {
100
98
  };
101
99
 
102
100
  const jsx = (tag, { children, ...props } = {}) => {
103
- // Fragment (Symbol-safe)
104
- if (tag === REACT_FRAGMENT_TYPE || tag === Fragment) {
101
+ // Fragment. It is the registered React symbol, so a compiled React library
102
+ // matches whether it imports Fragment or inlines Symbol.for("react.fragment")
103
+ if (tag === Fragment) {
105
104
  return () => renderChild(children);
106
105
  }
107
106
 
@@ -158,7 +157,9 @@ const jsx = (tag, { children, ...props } = {}) => {
158
157
  // `minify` drives <style>, it isn't an HTML attribute
159
158
  .filter(([k]) => !(tag === "style" && k === "minify"))
160
159
  .filter(([k, v]) => !/on[A-Z]/.test(k) && typeof v !== "function")
161
- .filter(([, v]) => v !== false)
160
+ // `false`, `null` and `undefined` drop the attribute (as React does),
161
+ // rather than rendering it empty
162
+ .filter(([, v]) => v !== false && v != null)
162
163
  .map(([k, v]) => {
163
164
  const preserveSvg = k === "viewBox" || k === "preserveAspectRatio";
164
165
 
@@ -180,6 +181,7 @@ const jsx = (tag, { children, ...props } = {}) => {
180
181
  return `${cssKey}:${val}`;
181
182
  })
182
183
  .join(";");
184
+ if (!v) return null; // an empty style object adds nothing
183
185
  }
184
186
 
185
187
  const value =
@@ -187,6 +189,7 @@ const jsx = (tag, { children, ...props } = {}) => {
187
189
 
188
190
  return `${key}="${value}"`;
189
191
  })
192
+ .filter((attr) => attr !== null)
190
193
  .join(" ");
191
194
 
192
195
  if (attrStr) attrStr = ` ${attrStr}`;
@@ -1,694 +0,0 @@
1
- /** @jsxImportSource . */
2
-
3
- expect.extend({
4
- toRender(fn, rendered) {
5
- const html = fn();
6
- if (this.isNot) {
7
- const msg = `Expected "${html}" to render differently`;
8
- return { pass: html === rendered, message: () => msg };
9
- }
10
- const msg = `Expected "${html}" to be rendered as "${rendered}"`;
11
- return { pass: html === rendered, message: () => msg };
12
- },
13
- });
14
-
15
- describe("jsx", () => {
16
- it("can render a div", () => {
17
- expect(<div>Hello</div>).toRender("<div>Hello</div>");
18
- });
19
-
20
- it("can render an input with attributes", () => {
21
- expect(<input name="hello" />).toRender(`<input name="hello" />`);
22
- });
23
-
24
- it("will stringify an attribute", () => {
25
- expect(<input value={5.2} />).toRender('<input value="5.2" />');
26
- });
27
-
28
- it("will ignore a boolean attribute", () => {
29
- expect(<input disabled />).toRender("<input disabled />");
30
- expect(<input disabled={true} />).toRender("<input disabled />");
31
- expect(<input disabled={false} />).toRender("<input />");
32
- });
33
-
34
- it("will remove events", () => {
35
- expect(<input onChange={() => console.log("hello")} />).toRender(
36
- "<input />",
37
- );
38
- });
39
-
40
- it("will inject the doctype for html", () => {
41
- // biome-ignore lint/a11y/useHtmlLang: This is an example, not user code
42
- expect(<html>Hello</html>).toRender("<!DOCTYPE html><html>Hello</html>");
43
- expect(<html lang="en">Hello</html>).toRender(
44
- `<!DOCTYPE html><html lang="en">Hello</html>`,
45
- );
46
- });
47
-
48
- it("can render a pre with text", () => {
49
- expect(<pre>hello</pre>).toRender("<pre>hello</pre>");
50
- });
51
-
52
- it("can render a pre with newlines", () => {
53
- expect(<pre>line1{"\n"}line2</pre>).toRender("<pre>line1\nline2</pre>");
54
- });
55
-
56
- it("can render a pre with indented text", () => {
57
- expect(<pre>{` indented`}</pre>).toRender("<pre> indented</pre>");
58
- });
59
-
60
- it("can render a pre inside a div", () => {
61
- expect(
62
- <div>
63
- <pre>line1{"\n"}line2</pre>
64
- </div>,
65
- ).toRender("<div><pre>line1\nline2</pre></div>");
66
- });
67
-
68
- it("does NOT render the <pre>", () => {
69
- expect(<code>{"<pre>"}</code>).toRender("<code>&lt;pre&gt;</code>");
70
- });
71
- });
72
-
73
- describe("text encoding", () => {
74
- it("encodes < and > to prevent XSS", () => {
75
- expect(<div>{"<script>alert(1)</script>"}</div>).toRender(
76
- "<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>",
77
- );
78
- });
79
-
80
- it("encodes & in text content", () => {
81
- expect(<div>{"a & b"}</div>).toRender("<div>a &amp; b</div>");
82
- });
83
-
84
- it('encodes " in text content', () => {
85
- expect(<div>{'say "hi"'}</div>).toRender("<div>say &quot;hi&quot;</div>");
86
- });
87
-
88
- it("encodes ' in text content", () => {
89
- expect(<div>{"it's"}</div>).toRender("<div>it&#39;s</div>");
90
- });
91
-
92
- it("renders 0 as a child", () => {
93
- expect(<div>{0}</div>).toRender("<div>0</div>");
94
- });
95
-
96
- it("renders positive integers", () => {
97
- expect(<div>{42}</div>).toRender("<div>42</div>");
98
- });
99
-
100
- it("renders floats", () => {
101
- expect(<div>{3.14}</div>).toRender("<div>3.14</div>");
102
- });
103
-
104
- it("renders negative numbers", () => {
105
- expect(<div>{-1}</div>).toRender("<div>-1</div>");
106
- });
107
-
108
- it("renders nothing for null", () => {
109
- expect(<div>{null}</div>).toRender("<div></div>");
110
- });
111
-
112
- it("renders nothing for undefined", () => {
113
- expect(<div>{undefined}</div>).toRender("<div></div>");
114
- });
115
-
116
- it("renders nothing for false", () => {
117
- expect(<div>{false}</div>).toRender("<div></div>");
118
- });
119
-
120
- it("renders nothing for true", () => {
121
- expect(<div>{true}</div>).toRender("<div></div>");
122
- });
123
-
124
- it("renders conditional content", () => {
125
- const show = true;
126
- expect(<div>{show && <span>yes</span>}</div>).toRender(
127
- "<div><span>yes</span></div>",
128
- );
129
- const hide = false;
130
- expect(<div>{hide && <span>no</span>}</div>).toRender("<div></div>");
131
- });
132
-
133
- it("renders multiple children in order", () => {
134
- expect(
135
- <ul>
136
- <li>a</li>
137
- <li>b</li>
138
- <li>c</li>
139
- </ul>,
140
- ).toRender("<ul><li>a</li><li>b</li><li>c</li></ul>");
141
- });
142
-
143
- it("renders array children", () => {
144
- const items = ["x", "y", "z"];
145
- expect(
146
- <span>
147
- {items.map((i) => (
148
- <b key={i}>{i}</b>
149
- ))}
150
- </span>,
151
- ).toRender("<span><b>x</b><b>y</b><b>z</b></span>");
152
- });
153
-
154
- it("treats string returned from a function child as raw HTML", () => {
155
- const getHtml = () => "<b>bold</b>";
156
- expect(<div>{getHtml}</div>).toRender("<div><b>bold</b></div>");
157
- });
158
- });
159
-
160
- describe("attribute encoding", () => {
161
- it("encodes < and > in attribute values", () => {
162
- expect(<div title="<b>hi</b>"></div>).toRender(
163
- '<div title="&lt;b&gt;hi&lt;/b&gt;"></div>',
164
- );
165
- });
166
-
167
- it("encodes & in attribute values", () => {
168
- expect(<div title="a & b"></div>).toRender('<div title="a &amp; b"></div>');
169
- });
170
-
171
- it('encodes " in attribute values', () => {
172
- expect(<div title={`say "hi"`}></div>).toRender(
173
- '<div title="say &quot;hi&quot;"></div>',
174
- );
175
- });
176
-
177
- it("encodes ' in attribute values", () => {
178
- expect(<div title={"it's"}></div>).toRender('<div title="it&#39;s"></div>');
179
- });
180
-
181
- it("maps className to class", () => {
182
- expect(<div className="foo bar"></div>).toRender(
183
- '<div class="foo bar"></div>',
184
- );
185
- });
186
-
187
- it("maps htmlFor to for", () => {
188
- expect(<label htmlFor="email"></label>).toRender(
189
- '<label for="email"></label>',
190
- );
191
- });
192
-
193
- it("supports data-* attributes", () => {
194
- expect(<div data-id="42" data-name="test"></div>).toRender(
195
- '<div data-id="42" data-name="test"></div>',
196
- );
197
- });
198
-
199
- it("supports aria-* attributes", () => {
200
- expect(<button aria-label="close" aria-expanded={false}></button>).toRender(
201
- '<button aria-label="close"></button>',
202
- );
203
- });
204
-
205
- it("supports numeric attribute values", () => {
206
- expect(<input tabIndex={3} />).toRender('<input tabIndex="3" />');
207
- });
208
-
209
- it("strips function-valued attributes", () => {
210
- expect(<div onFoo={() => {}} ref={() => {}}></div>).toRender("<div></div>");
211
- });
212
- });
213
-
214
- describe("self-closing tags", () => {
215
- it("renders br", () => {
216
- expect(<br />).toRender("<br />");
217
- });
218
-
219
- it("renders hr", () => {
220
- expect(<hr />).toRender("<hr />");
221
- });
222
-
223
- it("renders img with attributes", () => {
224
- expect(<img src="cat.png" alt="A cat" />).toRender(
225
- '<img src="cat.png" alt="A cat" />',
226
- );
227
- });
228
-
229
- it("renders input with type and name", () => {
230
- expect(<input type="text" name="q" />).toRender(
231
- '<input type="text" name="q" />',
232
- );
233
- });
234
-
235
- it("renders link tag", () => {
236
- expect(<link rel="stylesheet" href="style.css" />).toRender(
237
- '<link rel="stylesheet" href="style.css" />',
238
- );
239
- });
240
-
241
- it("renders meta tag", () => {
242
- expect(<meta name="description" content="hello" />).toRender(
243
- '<meta name="description" content="hello" />',
244
- );
245
- });
246
-
247
- it("renders source tag", () => {
248
- expect(<source src="video.mp4" type="video/mp4" />).toRender(
249
- '<source src="video.mp4" type="video/mp4" />',
250
- );
251
- });
252
- });
253
-
254
- describe("fragments", () => {
255
- it("does not crash on symbol-based element types (React internals)", () => {
256
- expect(
257
- <>
258
- <span>Hello</span> world
259
- </>,
260
- ).toRender("<span>Hello</span> world");
261
- });
262
-
263
- it("renders nested fragments", () => {
264
- expect(
265
- <>
266
- <>
267
- <span>A</span>
268
- </>
269
- B
270
- </>,
271
- ).toRender("<span>A</span>B");
272
- });
273
-
274
- it("handles empty fragments", () => {
275
- expect(<></>).toRender("");
276
- });
277
-
278
- it("encodes text inside a fragment", () => {
279
- expect(<>{"<b>"}</>).toRender("&lt;b&gt;");
280
- });
281
- });
282
-
283
- describe("script tag", () => {
284
- it("renders script content without encoding", () => {
285
- expect(<script>{"const x = 1 < 2 && 3 > 0;"}</script>).toRender(
286
- "<script>const x = 1 < 2 && 3 > 0;</script>",
287
- );
288
- });
289
-
290
- it("renders script with src attribute and no children", () => {
291
- expect(<script src="app.js"></script>).toRender(
292
- '<script src="app.js"></script>',
293
- );
294
- });
295
-
296
- it("renders empty script tag", () => {
297
- expect(<script></script>).toRender("<script></script>");
298
- });
299
- });
300
-
301
- describe("style tag", () => {
302
- it("renders the CSS as written", () => {
303
- expect(<style>{"body { color: red; }"}</style>).toRender(
304
- "<style>body { color: red; }</style>",
305
- );
306
- });
307
-
308
- it("keeps comments and whitespace without `minify`", () => {
309
- expect(<style>{"/* reset */ body {\n margin: 0;\n}"}</style>).toRender(
310
- "<style>/* reset */ body {\n margin: 0;\n}</style>",
311
- );
312
- });
313
-
314
- it("sends the content raw, like <script>", () => {
315
- // Encoding it would break the CSS, so user input must never reach here
316
- expect(<style>{"a::after { content: '>'; }"}</style>).toRender(
317
- "<style>a::after { content: '>'; }</style>",
318
- );
319
- });
320
-
321
- it("escapes </style> either way, so it can't close the tag early", () => {
322
- expect(<style>{"a { content: '</style>'; }"}</style>).toRender(
323
- "<style>a { content: '<\\/style>'; }</style>",
324
- );
325
- expect(<style minify>{"a { content: '</style>'; }"}</style>).toRender(
326
- "<style>a{content:'<\\/style>'}</style>",
327
- );
328
- });
329
-
330
- it("never renders `minify` as an attribute", () => {
331
- expect(<style minify>{"a { color: red; }"}</style>).toRender(
332
- "<style>a{color:red}</style>",
333
- );
334
- });
335
-
336
- describe("with `minify`", () => {
337
- it("minifies whitespace in style content", () => {
338
- expect(
339
- <style minify>{"body {\n color: red;\n margin: 0;\n}"}</style>,
340
- ).toRender("<style>body{color:red;margin:0}</style>");
341
- });
342
-
343
- it("strips CSS comments", () => {
344
- expect(
345
- <style minify>{"/* reset */ body { margin: 0; } /* end */"}</style>,
346
- ).toRender("<style>body{margin:0}</style>");
347
- });
348
-
349
- it("preserves child combinator in selectors", () => {
350
- expect(
351
- <style minify>{":not(pre) > code { background: none; }"}</style>,
352
- ).toRender("<style>:not(pre)>code{background:none}</style>");
353
- });
354
-
355
- it("preserves sibling combinators in selectors", () => {
356
- // `+` keeps a single space, since removing it breaks calc() below
357
- expect(
358
- <style minify>
359
- {"h2 + p { margin: 0; } h2 ~ p { color: red; }"}
360
- </style>,
361
- ).toRender("<style>h2 + p{margin:0}h2~p{color:red}</style>");
362
- });
363
-
364
- it("keeps the spaces calc() needs around + and -", () => {
365
- // `calc(100%+10px)` is invalid CSS: the operators need their whitespace
366
- expect(<style minify>{"a { top: calc(100% + 10px); }"}</style>).toRender(
367
- "<style>a{top:calc(100% + 10px)}</style>",
368
- );
369
- expect(<style minify>{"a { top: calc(100% - 10px); }"}</style>).toRender(
370
- "<style>a{top:calc(100% - 10px)}</style>",
371
- );
372
- });
373
-
374
- it("removes spaces around braces, colons, and semicolons", () => {
375
- expect(
376
- <style minify>{"a { color : red ; font-size : 1em ; }"}</style>,
377
- ).toRender("<style>a{color:red;font-size:1em}</style>");
378
- });
379
-
380
- it("removes trailing semicolon before closing brace", () => {
381
- expect(<style minify>{"p { margin: 0; padding: 0; }"}</style>).toRender(
382
- "<style>p{margin:0;padding:0}</style>",
383
- );
384
- });
385
-
386
- it("leaves quoted values alone", () => {
387
- // The spaces and delimiters in here are content, not formatting
388
- expect(<style minify>{'a::after { content: ", "; }'}</style>).toRender(
389
- '<style>a::after{content:", "}</style>',
390
- );
391
- expect(
392
- <style minify>{'a::after { content: "a; b: c"; }'}</style>,
393
- ).toRender('<style>a::after{content:"a; b: c"}</style>');
394
- expect(
395
- <style minify>{"a::after { content: 'x y'; }"}</style>,
396
- ).toRender("<style>a::after{content:'x y'}</style>");
397
- });
398
-
399
- it("handles an escaped quote inside a value", () => {
400
- expect(
401
- <style minify>{'a::after { content: "say \\" hi"; }'}</style>,
402
- ).toRender('<style>a::after{content:"say \\" hi"}</style>');
403
- });
404
-
405
- it("still strips a comment written inside a value", () => {
406
- // Comments are stripped first, unconditionally
407
- expect(
408
- <style minify>{'a::after { content: "/* hi */"; }'}</style>,
409
- ).toRender('<style>a::after{content:""}</style>');
410
- });
411
- });
412
- });
413
-
414
- describe("dangerouslySetInnerHTML", () => {
415
- it("renders raw HTML without encoding", () => {
416
- expect(
417
- <div dangerouslySetInnerHTML={{ __html: "<b>bold</b>" }}></div>,
418
- ).toRender("<div><b>bold</b></div>");
419
- });
420
-
421
- it("does not double-encode HTML entities", () => {
422
- expect(
423
- <div dangerouslySetInnerHTML={{ __html: "&amp; &lt;" }}></div>,
424
- ).toRender("<div>&amp; &lt;</div>");
425
- });
426
-
427
- it("preserves script tags in raw HTML", () => {
428
- expect(
429
- <div
430
- dangerouslySetInnerHTML={{ __html: "<script>alert(1)</script>" }}
431
- ></div>,
432
- ).toRender("<div><script>alert(1)</script></div>");
433
- });
434
-
435
- it("does not render dangerouslySetInnerHTML as an attribute", () => {
436
- expect(<div dangerouslySetInnerHTML={{ __html: "hi" }}></div>).toRender(
437
- "<div>hi</div>",
438
- );
439
- });
440
- });
441
-
442
- describe("function components", () => {
443
- it("renders a simple function component", () => {
444
- const Greeting = ({ name }) => <p>Hello, {name}!</p>;
445
- expect(<Greeting name="world" />).toRender("<p>Hello, world!</p>");
446
- });
447
-
448
- it("renders a component with children", () => {
449
- const Box = ({ children }) => <div className="box">{children}</div>;
450
- expect(
451
- <Box>
452
- <span>inside</span>
453
- </Box>,
454
- ).toRender('<div class="box"><span>inside</span></div>');
455
- });
456
-
457
- it("renders nested components", () => {
458
- const Inner = () => <em>inner</em>;
459
- const Outer = () => (
460
- <div>
461
- <Inner />
462
- </div>
463
- );
464
- expect(<Outer />).toRender("<div><em>inner</em></div>");
465
- });
466
-
467
- it("renders a component returning null", () => {
468
- const Empty = () => null;
469
- expect(<Empty />).toRender("");
470
- });
471
-
472
- it("renders a component returning a string", () => {
473
- const Text = () => "hello";
474
- expect(<Text />).toRender("hello");
475
- });
476
-
477
- it("encodes text returned by a component", () => {
478
- const Unsafe = () => "<script>alert(1)</script>";
479
- expect(<Unsafe />).toRender("&lt;script&gt;alert(1)&lt;/script&gt;");
480
- });
481
- });
482
-
483
- describe("forwardRef-like components", () => {
484
- it("renders an object with a render function", () => {
485
- const Button = { render: ({ children }) => <button>{children}</button> };
486
- expect(<Button>click me</Button>).toRender("<button>click me</button>");
487
- });
488
-
489
- it("passes props to render function", () => {
490
- const Input = {
491
- render: ({ type, placeholder }) => (
492
- <input type={type} placeholder={placeholder} />
493
- ),
494
- };
495
- expect(<Input type="email" placeholder="you@example.com" />).toRender(
496
- '<input type="email" placeholder="you@example.com" />',
497
- );
498
- });
499
- });
500
-
501
- describe("React element interop", () => {
502
- // Simulate a component built with React's jsx runtime, which returns plain
503
- // objects { type, props } instead of @server/next functions
504
- const reactEl = (type, props = {}) => ({ type, props });
505
-
506
- it("renders a React element returned from a function component", () => {
507
- const Greeting = () => reactEl("h1", { children: "Hello" });
508
- expect(<Greeting />).toRender("<h1>Hello</h1>");
509
- });
510
-
511
- it("renders nested React elements", () => {
512
- const Card = () =>
513
- reactEl("div", {
514
- children: [
515
- reactEl("h2", { children: "Title" }),
516
- reactEl("p", { children: "Body" }),
517
- ],
518
- });
519
- expect(<Card />).toRender("<div><h2>Title</h2><p>Body</p></div>");
520
- });
521
-
522
- it("renders React elements as children of a native element", () => {
523
- const child = reactEl("strong", { children: "bold" });
524
- expect(<p>{child}</p>).toRender("<p><strong>bold</strong></p>");
525
- });
526
-
527
- it("renders a mix of React elements and plain strings as children", () => {
528
- const em = reactEl("em", { children: "world" });
529
- expect(<p>Hello {em}!</p>).toRender("<p>Hello <em>world</em>!</p>");
530
- });
531
-
532
- it("renders a React element with attributes", () => {
533
- const Link = () =>
534
- reactEl("a", { href: "https://example.com", children: "click" });
535
- expect(<Link />).toRender(`<a href="https://example.com">click</a>`);
536
- });
537
-
538
- it("renders deeply nested React elements", () => {
539
- const Deep = () =>
540
- reactEl("div", {
541
- children: reactEl("ul", {
542
- children: [
543
- reactEl("li", { children: "one" }),
544
- reactEl("li", { children: "two" }),
545
- ],
546
- }),
547
- });
548
- expect(<Deep />).toRender("<div><ul><li>one</li><li>two</li></ul></div>");
549
- });
550
- });
551
- describe("styles", () => {
552
- it("stringifies style object into CSS", () => {
553
- expect(
554
- <div
555
- style={{
556
- display: "inline-block",
557
- padding: "2px 8px",
558
- borderRadius: "4px",
559
- }}
560
- />,
561
- ).toRender(
562
- '<div style="display:inline-block;padding:2px 8px;border-radius:4px"></div>',
563
- );
564
- });
565
-
566
- it("handles numbers and mixed style values", () => {
567
- expect(
568
- <div
569
- style={{
570
- fontSize: 12,
571
- lineHeight: 1.5,
572
- fontWeight: "600",
573
- }}
574
- />,
575
- ).toRender(
576
- '<div style="font-size:12;line-height:1.5;font-weight:600"></div>',
577
- );
578
- });
579
-
580
- it("converts camelCase CSS properties", () => {
581
- expect(
582
- <div
583
- style={{
584
- backgroundColor: "#fff",
585
- marginTop: "10px",
586
- }}
587
- />,
588
- ).toRender('<div style="background-color:#fff;margin-top:10px"></div>');
589
- });
590
-
591
- it("ignores null and undefined style values", () => {
592
- expect(
593
- <div
594
- style={{
595
- color: "red",
596
- padding: null,
597
- margin: undefined,
598
- }}
599
- />,
600
- ).toRender('<div style="color:red"></div>');
601
- });
602
- });
603
-
604
- describe("svg attributes", () => {
605
- it("converts strokeWidth to stroke-width", () => {
606
- expect(
607
- <svg>
608
- <circle strokeWidth="2" />
609
- </svg>,
610
- ).toRender('<svg><circle stroke-width="2"></circle></svg>');
611
- });
612
-
613
- it("converts fillOpacity to fill-opacity", () => {
614
- expect(
615
- <svg>
616
- <rect fillOpacity="0.5" />
617
- </svg>,
618
- ).toRender('<svg><rect fill-opacity="0.5"></rect></svg>');
619
- });
620
-
621
- it("converts strokeLinecap to stroke-linecap", () => {
622
- expect(
623
- <svg>
624
- <line strokeLinecap="round" />
625
- </svg>,
626
- ).toRender('<svg><line stroke-linecap="round"></line></svg>');
627
- });
628
-
629
- it("does NOT break viewBox casing", () => {
630
- expect(
631
- <svg viewBox="0 0 100 100">
632
- <rect />
633
- </svg>,
634
- ).toRender('<svg viewBox="0 0 100 100"><rect></rect></svg>');
635
- });
636
-
637
- it("handles mixed SVG attributes correctly", () => {
638
- expect(
639
- <svg viewBox="0 0 10 10">
640
- <circle strokeWidth="1" fillOpacity="0.2" />
641
- </svg>,
642
- ).toRender(
643
- '<svg viewBox="0 0 10 10"><circle stroke-width="1" fill-opacity="0.2"></circle></svg>',
644
- );
645
- });
646
-
647
- it("keeps viewBox unchanged", () => {
648
- expect(
649
- <svg viewBox="0 0 100 100">
650
- <rect />
651
- </svg>,
652
- ).toRender('<svg viewBox="0 0 100 100"><rect></rect></svg>');
653
- });
654
-
655
- it("does NOT convert viewBox to view-box", () => {
656
- expect(<svg viewBox="0 0 10 10"></svg>).toRender(
657
- '<svg viewBox="0 0 10 10"></svg>',
658
- );
659
- });
660
-
661
- it("converts strokeWidth to stroke-width", () => {
662
- expect(
663
- <svg>
664
- <circle strokeWidth="2" />
665
- </svg>,
666
- ).toRender('<svg><circle stroke-width="2"></circle></svg>');
667
- });
668
-
669
- it("converts fillOpacity to fill-opacity", () => {
670
- expect(
671
- <svg>
672
- <rect fillOpacity="0.5" />
673
- </svg>,
674
- ).toRender('<svg><rect fill-opacity="0.5"></rect></svg>');
675
- });
676
-
677
- it("converts strokeLinecap to stroke-linecap", () => {
678
- expect(
679
- <svg>
680
- <line strokeLinecap="round" />
681
- </svg>,
682
- ).toRender('<svg><line stroke-linecap="round"></line></svg>');
683
- });
684
-
685
- it("handles mixed SVG attributes correctly", () => {
686
- expect(
687
- <svg viewBox="0 0 10 10">
688
- <circle strokeWidth="1" fillOpacity="0.2" />
689
- </svg>,
690
- ).toRender(
691
- '<svg viewBox="0 0 10 10"><circle stroke-width="1" fill-opacity="0.2"></circle></svg>',
692
- );
693
- });
694
- });