@server/next 0.39.0 → 0.40.1
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 +15 -7
- package/index.js +65 -78
- package/package.json +4 -11
- package/src/jsx/jsx-runtime.js +24 -6
- package/src/jsx/jsx-runtime.test.jsx +94 -31
package/index.d.ts
CHANGED
|
@@ -143,7 +143,7 @@ type KVStore = {
|
|
|
143
143
|
keys: () => Promise<string[]>;
|
|
144
144
|
};
|
|
145
145
|
type Provider = "email" | "github" | "google" | "microsoft" | "discord" | "facebook" | "apple";
|
|
146
|
-
type Strategy = "cookie" | "jwt" | "token"
|
|
146
|
+
type Strategy = "cookie" | "jwt" | "token";
|
|
147
147
|
type AuthSession = {
|
|
148
148
|
id: string;
|
|
149
149
|
provider: Provider;
|
|
@@ -156,22 +156,30 @@ type AuthUser<T = Record<string, any>> = T & {
|
|
|
156
156
|
strategy: Strategy;
|
|
157
157
|
email: string;
|
|
158
158
|
};
|
|
159
|
-
type
|
|
159
|
+
type ProfileUser = {
|
|
160
|
+
id: string | number;
|
|
161
|
+
email: string;
|
|
162
|
+
} & Record<string, any>;
|
|
163
|
+
type AuthOption = `${Strategy}:${Provider}` | {
|
|
160
164
|
strategy: Strategy;
|
|
161
165
|
providers?: Provider | Provider[];
|
|
162
|
-
key?: string;
|
|
163
166
|
session?: StoreSource;
|
|
164
167
|
store?: StoreSource;
|
|
165
168
|
redirect?: string;
|
|
166
|
-
|
|
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;
|
|
167
173
|
};
|
|
168
174
|
type AuthSettings = {
|
|
169
175
|
providers: Provider[];
|
|
170
176
|
strategy: Strategy;
|
|
171
177
|
store: KVStore;
|
|
172
178
|
session: KVStore;
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
183
|
redirect: string;
|
|
176
184
|
};
|
|
177
185
|
type LogLevel = "info";
|
|
@@ -501,4 +509,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
|
|
|
501
509
|
}
|
|
502
510
|
declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
|
|
503
511
|
|
|
504
|
-
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 StoreSource, 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",
|
|
@@ -912,6 +916,13 @@ var json = (...args) => r().json(...args);
|
|
|
912
916
|
var file = (...args) => r().file(...args);
|
|
913
917
|
var redirect = (...args) => r().redirect(...args);
|
|
914
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
|
+
|
|
915
926
|
// src/helpers/jwt.ts
|
|
916
927
|
var enc = new TextEncoder();
|
|
917
928
|
var dec = new TextDecoder();
|
|
@@ -982,7 +993,7 @@ async function verifyJwt(token, secret) {
|
|
|
982
993
|
// src/auth/finishLogin.ts
|
|
983
994
|
async function finishLogin(ctx, input) {
|
|
984
995
|
const settings = ctx.options.auth;
|
|
985
|
-
const { strategy,
|
|
996
|
+
const { strategy, onLogin, onUser } = settings;
|
|
986
997
|
const key = String(input.key);
|
|
987
998
|
const auth2 = {
|
|
988
999
|
id: createId(),
|
|
@@ -992,22 +1003,28 @@ async function finishLogin(ctx, input) {
|
|
|
992
1003
|
email: input.email,
|
|
993
1004
|
time: (/* @__PURE__ */ new Date()).toISOString().replace(/\.[0-9]*/, "")
|
|
994
1005
|
};
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
|
|
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);
|
|
1002
1015
|
if (!strategy.includes("jwt")) {
|
|
1003
1016
|
await settings.session.set(auth2.id, auth2, { expires: "1w" });
|
|
1004
1017
|
}
|
|
1005
1018
|
if (strategy.includes("jwt")) {
|
|
1006
1019
|
const token = await signJwt(auth2, ctx.options.secret, 7 * 24 * 60 * 60);
|
|
1007
|
-
|
|
1020
|
+
const exposed = await onUser(user, ctx);
|
|
1021
|
+
assertUser(exposed, "onUser");
|
|
1022
|
+
return status(201).json({ ...exposed, token });
|
|
1008
1023
|
}
|
|
1009
1024
|
if (strategy.includes("token")) {
|
|
1010
|
-
|
|
1025
|
+
const exposed = await onUser(user, ctx);
|
|
1026
|
+
assertUser(exposed, "onUser");
|
|
1027
|
+
return status(201).json({ ...exposed, token: auth2.id });
|
|
1011
1028
|
}
|
|
1012
1029
|
if (strategy.includes("cookie")) {
|
|
1013
1030
|
return cookies("authentication", {
|
|
@@ -1018,7 +1035,6 @@ async function finishLogin(ctx, input) {
|
|
|
1018
1035
|
sameSite: "Lax"
|
|
1019
1036
|
}).redirect(settings.redirect);
|
|
1020
1037
|
}
|
|
1021
|
-
if (strategy.includes("key")) throw new Error("Key auth not supported yet");
|
|
1022
1038
|
throw new Error("Unknown auth type");
|
|
1023
1039
|
}
|
|
1024
1040
|
|
|
@@ -1128,11 +1144,15 @@ var callback = async (ctx) => {
|
|
|
1128
1144
|
const parsed = JSON.parse(body.user).name;
|
|
1129
1145
|
if (parsed) name = `${parsed.firstName} ${parsed.lastName}`.trim();
|
|
1130
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");
|
|
1131
1151
|
const res = await finishLogin(ctx, {
|
|
1132
1152
|
provider: "apple",
|
|
1133
|
-
key:
|
|
1134
|
-
email:
|
|
1135
|
-
user:
|
|
1153
|
+
key: profile.id,
|
|
1154
|
+
email: profile.email,
|
|
1155
|
+
user: profile
|
|
1136
1156
|
});
|
|
1137
1157
|
res.headers.append("set-cookie", clearState());
|
|
1138
1158
|
return res;
|
|
@@ -1181,17 +1201,15 @@ function oauthProvider(config2) {
|
|
|
1181
1201
|
}
|
|
1182
1202
|
});
|
|
1183
1203
|
if (!profileRes.ok) throw new Error(`${config2.name}: profile fetch failed`);
|
|
1184
|
-
const
|
|
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");
|
|
1185
1208
|
const res = await finishLogin(ctx, {
|
|
1186
1209
|
provider: config2.name,
|
|
1187
1210
|
key: profile.id,
|
|
1188
1211
|
email: profile.email,
|
|
1189
|
-
user:
|
|
1190
|
-
id: profile.id,
|
|
1191
|
-
name: profile.name,
|
|
1192
|
-
email: profile.email,
|
|
1193
|
-
picture: profile.picture
|
|
1194
|
-
}
|
|
1212
|
+
user: profile
|
|
1195
1213
|
});
|
|
1196
1214
|
res.headers.append("set-cookie", clearState());
|
|
1197
1215
|
return res;
|
|
@@ -1237,8 +1255,7 @@ async function emailLogin(ctx) {
|
|
|
1237
1255
|
provider: "email",
|
|
1238
1256
|
key: user.email,
|
|
1239
1257
|
email: user.email,
|
|
1240
|
-
user
|
|
1241
|
-
store: false
|
|
1258
|
+
user
|
|
1242
1259
|
});
|
|
1243
1260
|
}
|
|
1244
1261
|
async function emailRegister(ctx) {
|
|
@@ -1259,13 +1276,11 @@ async function emailRegister(ctx) {
|
|
|
1259
1276
|
time,
|
|
1260
1277
|
...data
|
|
1261
1278
|
};
|
|
1262
|
-
await store.set(email, user);
|
|
1263
1279
|
return finishLogin(ctx, {
|
|
1264
1280
|
provider: "email",
|
|
1265
1281
|
key: email,
|
|
1266
1282
|
email,
|
|
1267
|
-
user
|
|
1268
|
-
store: false
|
|
1283
|
+
user
|
|
1269
1284
|
});
|
|
1270
1285
|
}
|
|
1271
1286
|
async function emailResetPassword() {
|
|
@@ -1345,21 +1360,25 @@ var getUserProfile = async (code) => {
|
|
|
1345
1360
|
const email = emails.sort((a) => a.primary ? -1 : 1)[0]?.email;
|
|
1346
1361
|
return { ...profile, email };
|
|
1347
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
|
+
});
|
|
1348
1371
|
var callback2 = async (ctx) => {
|
|
1349
1372
|
checkState(ctx, ctx.url.query.state);
|
|
1350
|
-
const
|
|
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");
|
|
1351
1377
|
const res = await finishLogin(ctx, {
|
|
1352
1378
|
provider: "github",
|
|
1353
1379
|
key: profile.id,
|
|
1354
1380
|
email: profile.email,
|
|
1355
|
-
user:
|
|
1356
|
-
id: profile.id,
|
|
1357
|
-
name: profile.name,
|
|
1358
|
-
email: profile.email,
|
|
1359
|
-
picture: profile.avatar_url,
|
|
1360
|
-
location: profile.location,
|
|
1361
|
-
created: profile.created_at
|
|
1362
|
-
}
|
|
1381
|
+
user: profile
|
|
1363
1382
|
});
|
|
1364
1383
|
res.headers.append("set-cookie", clearState());
|
|
1365
1384
|
return res;
|
|
@@ -1409,7 +1428,7 @@ var providers_default = {
|
|
|
1409
1428
|
|
|
1410
1429
|
// src/auth/parseAuthOptions.ts
|
|
1411
1430
|
var defaultRedirect = "/user";
|
|
1412
|
-
function
|
|
1431
|
+
function defaultOnUser(fullUser) {
|
|
1413
1432
|
const { password: _password, ...user } = fullUser;
|
|
1414
1433
|
return user;
|
|
1415
1434
|
}
|
|
@@ -1424,19 +1443,6 @@ function parseAuthOptions(auth2, all) {
|
|
|
1424
1443
|
throw new Error("Auth options needs a strategy");
|
|
1425
1444
|
}
|
|
1426
1445
|
const strategy = auth2.strategy;
|
|
1427
|
-
if (strategy === "key") {
|
|
1428
|
-
const key = auth2.key || env.AUTH_KEY;
|
|
1429
|
-
if (!key) {
|
|
1430
|
-
throw new Error("`key` auth needs the AUTH_KEY env var (or auth.key)");
|
|
1431
|
-
}
|
|
1432
|
-
return {
|
|
1433
|
-
strategy,
|
|
1434
|
-
providers: [],
|
|
1435
|
-
key,
|
|
1436
|
-
redirect: auth2.redirect || defaultRedirect,
|
|
1437
|
-
cleanUser: auth2.cleanUser || defaultCleanUser
|
|
1438
|
-
};
|
|
1439
|
-
}
|
|
1440
1446
|
const list = Array.isArray(auth2.providers) ? auth2.providers : auth2.providers ? [auth2.providers] : [];
|
|
1441
1447
|
if (!list.length) {
|
|
1442
1448
|
throw new Error("Auth options needs a provider");
|
|
@@ -1448,7 +1454,8 @@ function parseAuthOptions(auth2, all) {
|
|
|
1448
1454
|
);
|
|
1449
1455
|
}
|
|
1450
1456
|
const redirect2 = auth2.redirect || defaultRedirect;
|
|
1451
|
-
const
|
|
1457
|
+
const { onProfile, onLogin, onLogout } = auth2;
|
|
1458
|
+
const onUser = auth2.onUser || defaultOnUser;
|
|
1452
1459
|
if (!auth2.store && !all.store) {
|
|
1453
1460
|
throw new Error("Need a userStore store for Auth");
|
|
1454
1461
|
}
|
|
@@ -1462,7 +1469,10 @@ function parseAuthOptions(auth2, all) {
|
|
|
1462
1469
|
strategy,
|
|
1463
1470
|
providers: list,
|
|
1464
1471
|
redirect: redirect2,
|
|
1465
|
-
|
|
1472
|
+
onProfile,
|
|
1473
|
+
onLogin,
|
|
1474
|
+
onUser,
|
|
1475
|
+
onLogout,
|
|
1466
1476
|
store: authStore,
|
|
1467
1477
|
session: sessionStore
|
|
1468
1478
|
};
|
|
@@ -2236,14 +2246,6 @@ async function verify(password, hash3) {
|
|
|
2236
2246
|
});
|
|
2237
2247
|
}
|
|
2238
2248
|
|
|
2239
|
-
// src/helpers/safeEqual.ts
|
|
2240
|
-
function safeEqual(a, b) {
|
|
2241
|
-
if (a.length !== b.length) return false;
|
|
2242
|
-
let diff = 0;
|
|
2243
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
2244
|
-
return diff === 0;
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
2249
|
// src/auth/findSessionId.ts
|
|
2248
2250
|
var validateToken = (authorization) => {
|
|
2249
2251
|
const [type2, id] = authorization.trim().split(" ");
|
|
@@ -2276,19 +2278,6 @@ function findSessionId(ctx) {
|
|
|
2276
2278
|
}
|
|
2277
2279
|
|
|
2278
2280
|
// src/auth/getUser.ts
|
|
2279
|
-
function getKeyUser(ctx) {
|
|
2280
|
-
const expected = ctx.options.auth.key;
|
|
2281
|
-
const header = ctx.headers.authorization;
|
|
2282
|
-
if (!header) return;
|
|
2283
|
-
const [type2, provided] = header.trim().split(" ");
|
|
2284
|
-
if (type2?.toLowerCase() !== "bearer" || !provided) {
|
|
2285
|
-
throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
|
|
2286
|
-
}
|
|
2287
|
-
if (!expected || !safeEqual(provided, expected)) {
|
|
2288
|
-
throw ServerError_default.AUTH_INVALID_TOKEN();
|
|
2289
|
-
}
|
|
2290
|
-
return { id: "key", strategy: "key", provider: "key" };
|
|
2291
|
-
}
|
|
2292
2281
|
async function getAuthSession(ctx) {
|
|
2293
2282
|
const strategy = ctx.options.auth.strategy;
|
|
2294
2283
|
if (strategy.includes("jwt")) {
|
|
@@ -2309,7 +2298,6 @@ async function getAuthSession(ctx) {
|
|
|
2309
2298
|
async function getUser(ctx) {
|
|
2310
2299
|
if (!ctx.options.auth) return;
|
|
2311
2300
|
const options = ctx.options.auth;
|
|
2312
|
-
if (options.strategy === "key") return getKeyUser(ctx);
|
|
2313
2301
|
const auth2 = await getAuthSession(ctx);
|
|
2314
2302
|
if (!auth2) return;
|
|
2315
2303
|
if (options.strategy !== auth2.strategy) {
|
|
@@ -2328,7 +2316,9 @@ async function getUser(ctx) {
|
|
|
2328
2316
|
if (!user) throw ServerError_default.AUTH_NO_USER();
|
|
2329
2317
|
user.strategy = auth2.strategy;
|
|
2330
2318
|
user.provider = auth2.provider;
|
|
2331
|
-
|
|
2319
|
+
const exposed = await ctx.options.auth.onUser(user, ctx);
|
|
2320
|
+
assertUser(exposed, "onUser");
|
|
2321
|
+
return exposed;
|
|
2332
2322
|
}
|
|
2333
2323
|
|
|
2334
2324
|
// src/auth/logout.ts
|
|
@@ -2338,15 +2328,13 @@ async function logout(ctx) {
|
|
|
2338
2328
|
if (!strategy.includes("jwt")) {
|
|
2339
2329
|
await ctx.options.auth.session.del(findSessionId(ctx));
|
|
2340
2330
|
}
|
|
2331
|
+
if (ctx.options.auth.onLogout) await ctx.options.auth.onLogout(ctx);
|
|
2341
2332
|
if (strategy.includes("token") || strategy.includes("jwt")) {
|
|
2342
2333
|
return { token: null };
|
|
2343
2334
|
}
|
|
2344
2335
|
if (strategy.includes("cookie")) {
|
|
2345
2336
|
return cookies({ authentication: null }).redirect("/");
|
|
2346
2337
|
}
|
|
2347
|
-
if (strategy.includes("key")) {
|
|
2348
|
-
throw new Error("Key auth not supported yet");
|
|
2349
|
-
}
|
|
2350
2338
|
throw new Error("Unknown auth type");
|
|
2351
2339
|
}
|
|
2352
2340
|
|
|
@@ -2362,7 +2350,6 @@ function auth(app) {
|
|
|
2362
2350
|
app.use(async function middle(ctx) {
|
|
2363
2351
|
ctx.user = await getUser(ctx);
|
|
2364
2352
|
});
|
|
2365
|
-
if (app.settings.auth.strategy === "key") return;
|
|
2366
2353
|
app.post("/auth/logout", logout);
|
|
2367
2354
|
const enabled = app.settings.auth.providers;
|
|
2368
2355
|
for (const name of oauth2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@server/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
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",
|
|
@@ -65,21 +64,15 @@
|
|
|
65
64
|
"tutorials": "docs/tutorials"
|
|
66
65
|
},
|
|
67
66
|
"dependencies": {
|
|
68
|
-
"bucket": "^0.
|
|
69
|
-
"polystore": "^0.
|
|
67
|
+
"bucket": "^0.7.1",
|
|
68
|
+
"polystore": "^0.24.0"
|
|
70
69
|
},
|
|
71
70
|
"devDependencies": {
|
|
72
71
|
"@types/bun": "^1.3.0",
|
|
73
|
-
"@types/jest": "^30.0.0",
|
|
74
72
|
"@types/node": "^24.10.0",
|
|
75
73
|
"bun": "^1.3.13",
|
|
76
74
|
"check-dts": "^0.8.2",
|
|
77
|
-
"jest": "^29.7.0",
|
|
78
75
|
"tsup": "^8.5.1",
|
|
79
76
|
"typescript": "^6.0.2"
|
|
80
|
-
},
|
|
81
|
-
"jest": {
|
|
82
|
-
"testEnvironment": "jest-environment-node",
|
|
83
|
-
"transform": {}
|
|
84
77
|
}
|
|
85
78
|
}
|
package/src/jsx/jsx-runtime.js
CHANGED
|
@@ -38,14 +38,30 @@ const encode = (str = "") => {
|
|
|
38
38
|
const isValidChild = (child) =>
|
|
39
39
|
child != null && child !== false && child !== true;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// A `</style>` inside the CSS would close the element early and let whatever
|
|
42
|
+
// follows run as HTML. The content is never encoded, so this runs on every
|
|
43
|
+
// <style>, minified or not.
|
|
44
|
+
const escapeCss = (str) => str.replace(/<\/style>/gi, "<\\/style>");
|
|
45
|
+
|
|
46
|
+
const minifyCss = (str) => {
|
|
47
|
+
// Quoted values are content, not formatting: the spaces and delimiters in
|
|
48
|
+
// `content: ", "` have to survive. They're set aside while the rest is
|
|
49
|
+
// squeezed, then put back. Comments go first and unconditionally, so one
|
|
50
|
+
// written inside a string is stripped along with the rest.
|
|
51
|
+
const quoted = [];
|
|
52
|
+
return str
|
|
43
53
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
54
|
+
.replace(
|
|
55
|
+
/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g,
|
|
56
|
+
(match) => `\0${quoted.push(match) - 1}\0`,
|
|
57
|
+
)
|
|
44
58
|
.replace(/\s+/g, " ")
|
|
45
|
-
|
|
59
|
+
// `+` is left out on purpose for `calc(100% + 10px)`
|
|
60
|
+
.replace(/\s*([{}:;,>~])\s*/g, "$1")
|
|
46
61
|
.replace(/;}/g, "}")
|
|
47
|
-
.replace(
|
|
62
|
+
.replace(/\0(\d+)\0/g, (_, i) => quoted[i])
|
|
48
63
|
.trim();
|
|
64
|
+
};
|
|
49
65
|
|
|
50
66
|
// React element detection (safe for custom objects too)
|
|
51
67
|
const isReactElement = (val) =>
|
|
@@ -115,9 +131,9 @@ const jsx = (tag, { children, ...props } = {}) => {
|
|
|
115
131
|
children = raw(content);
|
|
116
132
|
}
|
|
117
133
|
|
|
118
|
-
// style: minified
|
|
134
|
+
// style: raw content, minified only when asked for with <style minify>
|
|
119
135
|
if (tag === "style" && typeof children === "string") {
|
|
120
|
-
children = raw(minifyCss(children));
|
|
136
|
+
children = raw(escapeCss(props?.minify ? minifyCss(children) : children));
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
// dangerouslySetInnerHTML
|
|
@@ -139,6 +155,8 @@ const jsx = (tag, { children, ...props } = {}) => {
|
|
|
139
155
|
// attributes
|
|
140
156
|
let attrStr = Object.entries(props || {})
|
|
141
157
|
.filter(([k]) => k !== "dangerouslySetInnerHTML")
|
|
158
|
+
// `minify` drives <style>, it isn't an HTML attribute
|
|
159
|
+
.filter(([k]) => !(tag === "style" && k === "minify"))
|
|
142
160
|
.filter(([k, v]) => !/on[A-Z]/.test(k) && typeof v !== "function")
|
|
143
161
|
.filter(([, v]) => v !== false)
|
|
144
162
|
.map(([k, v]) => {
|
|
@@ -299,52 +299,115 @@ describe("script tag", () => {
|
|
|
299
299
|
});
|
|
300
300
|
|
|
301
301
|
describe("style tag", () => {
|
|
302
|
-
it("renders
|
|
302
|
+
it("renders the CSS as written", () => {
|
|
303
303
|
expect(<style>{"body { color: red; }"}</style>).toRender(
|
|
304
|
-
"<style>body{color:red}</style>",
|
|
304
|
+
"<style>body { color: red; }</style>",
|
|
305
305
|
);
|
|
306
306
|
});
|
|
307
307
|
|
|
308
|
-
it("
|
|
309
|
-
expect(
|
|
310
|
-
<style
|
|
311
|
-
).toRender("<style>body{color:red;margin:0}</style>");
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it("strips CSS comments", () => {
|
|
315
|
-
expect(
|
|
316
|
-
<style>{"/* reset */ body { margin: 0; } /* end */"}</style>,
|
|
317
|
-
).toRender("<style>body{margin:0}</style>");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("preserves child combinator in selectors", () => {
|
|
321
|
-
expect(<style>{":not(pre) > code { background: none; }"}</style>).toRender(
|
|
322
|
-
"<style>:not(pre)>code{background:none}</style>",
|
|
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>",
|
|
323
311
|
);
|
|
324
312
|
});
|
|
325
313
|
|
|
326
|
-
it("
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
);
|
|
330
319
|
});
|
|
331
320
|
|
|
332
|
-
it("
|
|
333
|
-
expect(<style>{"a {
|
|
334
|
-
"<style>a{
|
|
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>",
|
|
335
327
|
);
|
|
336
328
|
});
|
|
337
329
|
|
|
338
|
-
it("
|
|
339
|
-
expect(<style>{"
|
|
340
|
-
"<style>
|
|
330
|
+
it("never renders `minify` as an attribute", () => {
|
|
331
|
+
expect(<style minify>{"a { color: red; }"}</style>).toRender(
|
|
332
|
+
"<style>a{color:red}</style>",
|
|
341
333
|
);
|
|
342
334
|
});
|
|
343
335
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
});
|
|
348
411
|
});
|
|
349
412
|
});
|
|
350
413
|
|