create-nexwire 4.0.0 → 4.1.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/README.md +5 -4
- package/express/.env +1 -1
- package/express/README.md +13 -7
- package/express/package.json +2 -2
- package/express/src/config/session.ts +1 -1
- package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/express/src/database/schema.ts +3 -3
- package/express/src/framework/cache/cache.ts +82 -81
- package/express/src/framework/database/seed.ts +1 -1
- package/express/src/framework/facade.ts +1 -24
- package/express/src/framework/http/app.ts +5 -5
- package/express/src/framework/http/openapi.ts +1 -1
- package/express/src/framework/http/router.ts +3 -18
- package/express/src/framework/http/types.ts +19 -0
- package/express/src/framework/http/validation.ts +18 -18
- package/express/src/framework/maker-cli/middleware/core.mjs +41 -47
- package/express/src/framework/maker-cli/module/core.mjs +920 -684
- package/express/src/framework/maker-cli/module/index.mjs +202 -140
- package/express/src/framework/maker-cli/runtime/core.mjs +1 -2
- package/express/src/framework/maker-cli/stubs/controller/openapi.ts.stub +40 -14
- package/express/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +40 -15
- package/express/src/framework/maker-cli/stubs/controller/plain.ts.stub +42 -16
- package/express/src/framework/maker-cli/stubs/controller/standalone.ts.stub +47 -0
- package/express/src/framework/maker-cli/stubs/facade/name.ts.stub +12 -0
- package/express/src/framework/maker-cli/stubs/helper/name.ts.stub +14 -0
- package/express/src/framework/maker-cli/stubs/middleware/name.local.ts.stub +15 -0
- package/express/src/framework/maker-cli/stubs/model/name.mysql.ts.stub +9 -8
- package/express/src/framework/maker-cli/stubs/model/name.postgresql.ts.stub +9 -8
- package/express/src/framework/maker-cli/stubs/model/name.sqlite.ts.stub +9 -8
- package/express/src/framework/maker-cli/stubs/notification/route.api.ts.stub +50 -50
- package/express/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +11 -11
- package/express/src/framework/maker-cli/stubs/route/api.ts.stub +93 -79
- package/express/src/framework/maker-cli/stubs/route/plain.ts.stub +23 -10
- package/express/src/framework/maker-cli/stubs/route/standalone.api.ts.stub +113 -0
- package/express/src/framework/maker-cli/stubs/route/standalone.plain.ts.stub +37 -0
- package/express/src/framework/maker-cli/stubs/{controller/schema.plain.ts.stub → schema/name.plain.ts.stub} +13 -13
- package/express/src/framework/maker-cli/stubs/{controller/schema.ts.stub → schema/name.ts.stub} +32 -32
- package/express/src/framework/maker-cli/stubs/service/name.plain.ts.stub +67 -0
- package/express/src/framework/maker-cli/stubs/service/name.ts.stub +65 -0
- package/express/src/framework/maker-cli/stubs/type/name.ts.stub +22 -0
- package/express/src/framework/maker-cli/utils/naming.mjs +56 -13
- package/express/src/framework/modules/discover.ts +57 -54
- package/express/src/framework/notification/index.ts +1 -1
- package/express/src/framework/realtime/broadcast.ts +46 -46
- package/express/src/framework/server.ts +2 -1
- package/express/src/framework/session/session.ts +102 -98
- package/express/src/framework/support/mail.ts +48 -48
- package/express/src/framework/support/status-codes.ts +75 -0
- package/express/src/middlewares/auth-middleware.ts +1 -1
- package/express/src/modules/auth/__tests__/forgetpass.test.ts +200 -0
- package/express/src/modules/auth/__tests__/login.test.ts +171 -0
- package/express/src/modules/auth/__tests__/logout.test.ts +60 -0
- package/express/src/modules/auth/__tests__/logoutalldevices.test.ts +49 -0
- package/express/src/modules/auth/__tests__/me.test.ts +91 -0
- package/express/src/modules/auth/__tests__/refreshtoken.test.ts +305 -0
- package/express/src/modules/auth/__tests__/register.test.ts +291 -0
- package/express/src/modules/auth/__tests__/resetpass.test.ts +206 -0
- package/express/src/modules/auth/__tests__/verifyemail.test.ts +183 -0
- package/express/src/modules/auth/controllers/auth.ts +254 -0
- package/express/src/modules/auth/controllers/{role.controller.ts → role.ts} +25 -15
- package/{hono/src/modules/auth/database/models/notifications.ts → express/src/modules/auth/database/models/notification.ts} +1 -1
- package/express/src/modules/auth/database/models/role.ts +1 -1
- package/express/src/modules/auth/database/models/user.ts +1 -1
- package/express/src/modules/auth/facade.ts +12 -0
- package/express/src/modules/auth/{controllers/auth.helpers.ts → helpers/auth.ts} +3 -4
- package/express/src/modules/auth/jobs/registeruser.ts +23 -23
- package/express/src/modules/auth/routes/{api.ts → index.ts} +150 -150
- package/express/src/modules/auth/routes/role.ts +33 -33
- package/{hono/src/modules/auth/controllers/auth.schema.ts → express/src/modules/auth/schemas/auth.ts} +102 -102
- package/express/src/modules/auth/services/forgetpassword.ts +66 -0
- package/express/src/modules/auth/services/login.ts +63 -0
- package/express/src/modules/auth/services/logout.ts +25 -0
- package/express/src/modules/auth/services/logoutalldevices.ts +19 -0
- package/express/src/modules/auth/services/me.ts +31 -0
- package/express/src/modules/auth/services/refreshtoken.ts +110 -0
- package/express/src/modules/auth/services/register.ts +141 -0
- package/express/src/modules/auth/services/resetpassword.ts +69 -0
- package/express/src/modules/auth/services/verifyemail.ts +77 -0
- package/express/src/modules/auth/types/auth.ts +31 -0
- package/express/src/resources/src/assets/images/logo.png +0 -0
- package/express/src/resources/src/pages/auth/forgetPassword.vue +1 -0
- package/express/src/resources/src/pages/auth/login.vue +1 -1
- package/express/src/storage/database/nexwire.sqlite +0 -0
- package/hono/.env +1 -1
- package/hono/README.md +13 -7
- package/hono/package.json +1 -1
- package/hono/src/config/session.ts +1 -1
- package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/hono/src/database/schema.ts +3 -3
- package/hono/src/framework/cache/cache.ts +82 -81
- package/hono/src/framework/database/seed.ts +1 -1
- package/hono/src/framework/http/app.ts +5 -5
- package/hono/src/framework/http/validation.ts +21 -21
- package/hono/src/framework/maker-cli/middleware/core.mjs +40 -46
- package/hono/src/framework/maker-cli/module/core.mjs +401 -168
- package/hono/src/framework/maker-cli/module/index.mjs +73 -11
- package/hono/src/framework/maker-cli/stubs/controller/openapi.ts.stub +28 -16
- package/hono/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +28 -17
- package/hono/src/framework/maker-cli/stubs/controller/plain.ts.stub +28 -16
- package/hono/src/framework/maker-cli/stubs/controller/standalone.ts.stub +56 -0
- package/hono/src/framework/maker-cli/stubs/facade/name.ts.stub +12 -0
- package/hono/src/framework/maker-cli/stubs/helper/name.ts.stub +14 -0
- package/hono/src/framework/maker-cli/stubs/middleware/name.local.ts.stub +15 -0
- package/hono/src/framework/maker-cli/stubs/model/name.mysql.ts.stub +9 -8
- package/hono/src/framework/maker-cli/stubs/model/name.postgresql.ts.stub +9 -8
- package/hono/src/framework/maker-cli/stubs/model/name.sqlite.ts.stub +9 -8
- package/hono/src/framework/maker-cli/stubs/notification/route.api.ts.stub +49 -49
- package/hono/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +11 -11
- package/hono/src/framework/maker-cli/stubs/route/api.ts.stub +93 -79
- package/hono/src/framework/maker-cli/stubs/route/plain.ts.stub +23 -10
- package/hono/src/framework/maker-cli/stubs/route/standalone.api.ts.stub +112 -0
- package/hono/src/framework/maker-cli/stubs/route/standalone.plain.ts.stub +37 -0
- package/hono/src/framework/maker-cli/stubs/{controller/schema.plain.ts.stub → schema/name.plain.ts.stub} +13 -13
- package/hono/src/framework/maker-cli/stubs/{controller/schema.ts.stub → schema/name.ts.stub} +32 -32
- package/hono/src/framework/maker-cli/stubs/service/name.plain.ts.stub +67 -0
- package/hono/src/framework/maker-cli/stubs/service/name.ts.stub +65 -0
- package/hono/src/framework/maker-cli/stubs/type/name.ts.stub +22 -0
- package/hono/src/framework/maker-cli/utils/naming.mjs +56 -13
- package/hono/src/framework/modules/discover.ts +57 -54
- package/hono/src/framework/notification/index.ts +1 -1
- package/hono/src/framework/realtime/broadcast.ts +46 -46
- package/hono/src/framework/server.ts +1 -1
- package/hono/src/framework/session/session.ts +121 -116
- package/hono/src/middlewares/auth-middleware.ts +1 -1
- package/hono/src/modules/auth/__tests__/forgetpass.test.ts +170 -0
- package/hono/src/modules/auth/__tests__/login.test.ts +154 -0
- package/hono/src/modules/auth/__tests__/logout.test.ts +53 -0
- package/hono/src/modules/auth/__tests__/logoutalldevices.test.ts +34 -0
- package/hono/src/modules/auth/__tests__/me.test.ts +87 -0
- package/hono/src/modules/auth/__tests__/refreshtoken.test.ts +305 -0
- package/hono/src/modules/auth/__tests__/register.test.ts +258 -0
- package/hono/src/modules/auth/__tests__/resetpass.test.ts +214 -0
- package/hono/src/modules/auth/__tests__/verifyemail.test.ts +189 -0
- package/hono/src/modules/auth/controllers/auth.ts +238 -0
- package/hono/src/modules/auth/controllers/{role.controller.ts → role.ts} +25 -25
- package/{express/src/modules/auth/database/models/notifications.ts → hono/src/modules/auth/database/models/notification.ts} +1 -1
- package/hono/src/modules/auth/database/models/role.ts +1 -1
- package/hono/src/modules/auth/database/models/user.ts +1 -1
- package/hono/src/modules/auth/facade.ts +12 -0
- package/hono/src/modules/auth/{controllers/auth.helpers.ts → helpers/auth.ts} +5 -6
- package/hono/src/modules/auth/jobs/registeruser.ts +23 -23
- package/hono/src/modules/auth/routes/{api.ts → index.ts} +150 -150
- package/hono/src/modules/auth/routes/role.ts +33 -33
- package/{express/src/modules/auth/controllers/auth.schema.ts → hono/src/modules/auth/schemas/auth.ts} +102 -102
- package/hono/src/modules/auth/services/forgetpassword.ts +58 -0
- package/hono/src/modules/auth/services/login.ts +61 -0
- package/hono/src/modules/auth/services/logout.ts +24 -0
- package/hono/src/modules/auth/services/logoutalldevices.ts +19 -0
- package/hono/src/modules/auth/services/me.ts +31 -0
- package/hono/src/modules/auth/services/refreshtoken.ts +110 -0
- package/hono/src/modules/auth/services/register.ts +132 -0
- package/hono/src/modules/auth/services/resetpassword.ts +69 -0
- package/hono/src/modules/auth/services/verifyemail.ts +77 -0
- package/hono/src/modules/auth/types/auth.ts +31 -0
- package/hono/src/resources/src/assets/images/logo.png +0 -0
- package/hono/src/storage/database/nexwire.sqlite +0 -0
- package/package.json +1 -1
- package/express/src/framework/maker-cli/stubs/example/console.ts.stub +0 -34
- package/express/src/framework/maker-cli/stubs/example/controller.ts.stub +0 -418
- package/express/src/framework/maker-cli/stubs/example/job.ts.stub +0 -75
- package/express/src/framework/maker-cli/stubs/example/route.api.ts.stub +0 -207
- package/express/src/framework/maker-cli/stubs/example/route.plain.ts.stub +0 -18
- package/express/src/framework/maker-cli/stubs/example/schema.plain.ts.stub +0 -42
- package/express/src/framework/maker-cli/stubs/example/schema.ts.stub +0 -42
- package/express/src/modules/auth/controllers/auth.controller.ts +0 -358
- package/hono/src/framework/maker-cli/stubs/example/console.ts.stub +0 -34
- package/hono/src/framework/maker-cli/stubs/example/controller.ts.stub +0 -505
- package/hono/src/framework/maker-cli/stubs/example/job.ts.stub +0 -75
- package/hono/src/framework/maker-cli/stubs/example/route.api.ts.stub +0 -207
- package/hono/src/framework/maker-cli/stubs/example/route.plain.ts.stub +0 -18
- package/hono/src/framework/maker-cli/stubs/example/schema.plain.ts.stub +0 -42
- package/hono/src/framework/maker-cli/stubs/example/schema.ts.stub +0 -42
- package/hono/src/modules/auth/controllers/auth.controller.ts +0 -413
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
2
|
+
import { db, password } from "@/framework/facade.js";
|
|
3
|
+
import { passwordResetTokens, refreshTokens, users } from "@/modules/auth/database/models/user.js";
|
|
4
|
+
import { hashResetToken } from "@/modules/auth/helpers/auth.js";
|
|
5
|
+
import type { ResetPasswordInput } from "@/modules/auth/types/auth.js";
|
|
6
|
+
|
|
7
|
+
export type ResetPasswordResult = { kind: "invalid_token"; message: string } | { kind: "reset"; message: string };
|
|
8
|
+
|
|
9
|
+
export const resetPasswordService = {
|
|
10
|
+
resetPassword: async (body: ResetPasswordInput): Promise<ResetPasswordResult> => {
|
|
11
|
+
const user = await db.query.users.findFirst({
|
|
12
|
+
where: eq(users.email, body.email)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (!user) {
|
|
16
|
+
return {
|
|
17
|
+
kind: "invalid_token",
|
|
18
|
+
message: "Invalid or expired reset token"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hashedPassword = await password.hashPassword(body.password);
|
|
23
|
+
const hashedToken = hashResetToken(body.token);
|
|
24
|
+
const now = new Date();
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const consumed = await db.transaction(async (tx) => {
|
|
28
|
+
// Consume the token atomically inside the transaction.
|
|
29
|
+
// If anything below fails, the delete rolls back and the
|
|
30
|
+
// user can retry with the same token.
|
|
31
|
+
const [row] = await tx
|
|
32
|
+
.delete(passwordResetTokens)
|
|
33
|
+
.where(
|
|
34
|
+
and(
|
|
35
|
+
eq(passwordResetTokens.email, body.email),
|
|
36
|
+
eq(passwordResetTokens.token, hashedToken),
|
|
37
|
+
gt(passwordResetTokens.expiresAt, now)
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
.returning();
|
|
41
|
+
|
|
42
|
+
if (!row) return false;
|
|
43
|
+
|
|
44
|
+
await tx.update(users).set({ password: hashedPassword, updatedAt: now }).where(eq(users.id, user.id));
|
|
45
|
+
|
|
46
|
+
await tx.update(refreshTokens).set({ revoked: 1 }).where(eq(refreshTokens.userId, user.id));
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!consumed) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "invalid_token",
|
|
54
|
+
message: "Invalid or expired reset token"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { kind: "reset", message: "Password reset successfully" };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Token was NOT consumed — the transaction rolled back, so the
|
|
61
|
+
// user can retry with the same token. Log so failures aren't silent.
|
|
62
|
+
// logger.error({ err, email: body.email }, "password reset failed");
|
|
63
|
+
return {
|
|
64
|
+
kind: "invalid_token",
|
|
65
|
+
message: "Invalid or expired reset token"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
2
|
+
import { authConfig } from "@/config/index.js";
|
|
3
|
+
import { db } from "@/framework/facade.js";
|
|
4
|
+
import { emailVerificationTokens, users } from "@/modules/auth/database/models/user.js";
|
|
5
|
+
import { hashEmailVerificationToken } from "@/modules/auth/helpers/auth.js";
|
|
6
|
+
import type { VerifyEmailInput } from "@/modules/auth/types/auth.js";
|
|
7
|
+
|
|
8
|
+
export type VerifyEmailResult =
|
|
9
|
+
| { kind: "not_required"; message: string }
|
|
10
|
+
| { kind: "invalid_token"; message: string }
|
|
11
|
+
| { kind: "verified"; message: string };
|
|
12
|
+
|
|
13
|
+
export const verifyEmailService = {
|
|
14
|
+
verifyEmail: async (body: VerifyEmailInput): Promise<VerifyEmailResult> => {
|
|
15
|
+
if (!authConfig.requireEmailVerification) {
|
|
16
|
+
return {
|
|
17
|
+
kind: "not_required",
|
|
18
|
+
message: "Email verification is not required"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const user = await db.query.users.findFirst({
|
|
23
|
+
where: eq(users.email, body.email)
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!user) {
|
|
27
|
+
return {
|
|
28
|
+
kind: "invalid_token",
|
|
29
|
+
message: "Invalid or expired verification token"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hashedToken = hashEmailVerificationToken(body.token);
|
|
34
|
+
const now = new Date();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const verified = await db.transaction(async (tx) => {
|
|
38
|
+
// Consume the token atomically INSIDE the transaction.
|
|
39
|
+
// If the update below fails, the delete rolls back and the
|
|
40
|
+
// user can retry with the same token.
|
|
41
|
+
const [consumed] = await tx
|
|
42
|
+
.delete(emailVerificationTokens)
|
|
43
|
+
.where(
|
|
44
|
+
and(
|
|
45
|
+
eq(emailVerificationTokens.email, body.email),
|
|
46
|
+
eq(emailVerificationTokens.token, hashedToken),
|
|
47
|
+
gt(emailVerificationTokens.expiresAt, now)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
.returning();
|
|
51
|
+
|
|
52
|
+
if (!consumed) return false;
|
|
53
|
+
|
|
54
|
+
await tx.update(users).set({ emailVerifiedAt: now, updatedAt: now }).where(eq(users.id, user.id));
|
|
55
|
+
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!verified) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "invalid_token",
|
|
62
|
+
message: "Invalid or expired verification token"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { kind: "verified", message: "Email verified successfully" };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// Token was NOT consumed — the transaction rolled back, so the
|
|
69
|
+
// user can retry with the same token. Log so failures aren't silent.
|
|
70
|
+
// logger.error({ err, email: body.email }, "email verification failed");
|
|
71
|
+
return {
|
|
72
|
+
kind: "invalid_token",
|
|
73
|
+
message: "Invalid or expired verification token"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type RegisterInput = {
|
|
2
|
+
name: string;
|
|
3
|
+
email: string;
|
|
4
|
+
password: string;
|
|
5
|
+
remember?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type LoginInput = {
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
remember?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ForgotPasswordInput = {
|
|
15
|
+
email: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ResetPasswordInput = {
|
|
19
|
+
email: string;
|
|
20
|
+
token: string;
|
|
21
|
+
password: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type VerifyEmailInput = {
|
|
25
|
+
email: string;
|
|
26
|
+
token: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type RefreshTokenInput = {
|
|
30
|
+
refresh_token: string;
|
|
31
|
+
};
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { defineSchedule, dispatchEvent } from "@/framework/facade.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* name: Unique schedule id used as the distributed lock key.
|
|
5
|
-
* expression: Cron expression that controls when the task runs.
|
|
6
|
-
* timezone: IANA timezone for cron evaluation (example: "UTC").
|
|
7
|
-
* runOnInit: Runs once immediately when scheduler starts.
|
|
8
|
-
* enabled: Enables or disables this schedule without deleting code.
|
|
9
|
-
* ttlMs: Lock TTL in milliseconds to avoid duplicate execution.
|
|
10
|
-
*/
|
|
11
|
-
defineSchedule({
|
|
12
|
-
name: "{{module}}:scheduler-tick",
|
|
13
|
-
expression: "*/30 * * * * *",
|
|
14
|
-
// timezone: "UTC",
|
|
15
|
-
runOnInit: true,
|
|
16
|
-
// enabled: true,
|
|
17
|
-
// ttlMs: 120000,
|
|
18
|
-
handler: async () => {
|
|
19
|
-
/**
|
|
20
|
-
* dispatchEvent name: Event id to publish to listeners.
|
|
21
|
-
* payload: Data sent to queue/broadcast consumers.
|
|
22
|
-
* queue: Optional queue target for async event consumers.
|
|
23
|
-
* broadcast: Optional realtime fanout (all/users/roles/rooms).
|
|
24
|
-
*/
|
|
25
|
-
await dispatchEvent("{{module}}.scheduler.tick", {
|
|
26
|
-
origin: "scheduler",
|
|
27
|
-
createdAt: new Date().toISOString()
|
|
28
|
-
}, {
|
|
29
|
-
queue: "default",
|
|
30
|
-
broadcast: { all: true }
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import type { Request, Response } from "express";
|
|
3
|
-
import { cache, dispatchCommand, dispatchEvent, session, storage } from "@/framework/facade.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Example controller guide:
|
|
7
|
-
* - req.body / req.params: validated payloads from the route schema (validation middleware runs first).
|
|
8
|
-
* - dispatchEvent(name, payload, options): emits domain events.
|
|
9
|
-
* - dispatchCommand(name, payload, { async, queue }): queues command-style work.
|
|
10
|
-
* - storage.generateForDownload(...): creates one-time temp download token.
|
|
11
|
-
* - storage.consumeGenerated(token): reads temp file and deletes it.
|
|
12
|
-
* - cache.put/get/remember: lightweight state for async flows (PDF status).
|
|
13
|
-
* - securityMiddleware: sets CSP, HSTS, X-Frame headers (toggle via SECURITY_HEADERS env).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
type PdfRequest = {
|
|
17
|
-
title: string;
|
|
18
|
-
rows: number;
|
|
19
|
-
chunkSize: number;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
function pdfStatusKey(requestId: string) {
|
|
23
|
-
return "{{module}}:pdf:status:" + requestId;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function sessionId(res: Response) {
|
|
27
|
-
return res.locals.sessionId as string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Why: Demonstrates event dispatch with both queue and websocket broadcast.
|
|
32
|
-
* When: Use as the baseline event example for async + realtime flow.
|
|
33
|
-
* Where: POST /{{module}}/event.
|
|
34
|
-
*/
|
|
35
|
-
export const fire = async (req: Request, res: Response) => {
|
|
36
|
-
// Route validates body using ExampleEventSchema in route stub.
|
|
37
|
-
const body = req.body;
|
|
38
|
-
|
|
39
|
-
// Session example: keeps last payload for this visitor/session.
|
|
40
|
-
await session.put(sessionId(res), "lastExampleEvent", body);
|
|
41
|
-
|
|
42
|
-
// Event options shown here:
|
|
43
|
-
// - queue: true -> send to default async event queue
|
|
44
|
-
// - broadcast: { all: true } -> fanout to realtime listeners
|
|
45
|
-
await dispatchEvent("{{module}}.created", body, {
|
|
46
|
-
queue: true,
|
|
47
|
-
broadcast: { all: true }
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
res.status(202).json({ message: "Event dispatched", data: body });
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Why: Demonstrates command-style background processing via queue.
|
|
55
|
-
* When: Use when work should run asynchronously and return 202 immediately.
|
|
56
|
-
* Where: POST /{{module}}/queue/command.
|
|
57
|
-
*/
|
|
58
|
-
export const queueCommand = async (req: Request, res: Response) => {
|
|
59
|
-
const body = req.body;
|
|
60
|
-
|
|
61
|
-
// Command options:
|
|
62
|
-
// - async: true -> run in worker, do not block request
|
|
63
|
-
// - queue: "default" -> explicit queue channel
|
|
64
|
-
await dispatchCommand("{{module}}.process", body, {
|
|
65
|
-
async: true,
|
|
66
|
-
queue: "default"
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
res.status(202).json({ message: "Queue command dispatched", data: body });
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Why: Demonstrates queued events without realtime fanout.
|
|
74
|
-
* When: Use when consumers should process event in workers only.
|
|
75
|
-
* Where: POST /{{module}}/event/queued.
|
|
76
|
-
*/
|
|
77
|
-
export const fireQueued = async (req: Request, res: Response) => {
|
|
78
|
-
const body = req.body;
|
|
79
|
-
|
|
80
|
-
await dispatchEvent("{{module}}.created", body, { queue: "default" });
|
|
81
|
-
|
|
82
|
-
res.status(202).json({ message: "Queued event dispatched", data: body });
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Why: Demonstrates one event feeding both queue workers and broadcast listeners.
|
|
87
|
-
* When: Use when background processing and client updates are both needed.
|
|
88
|
-
* Where: POST /{{module}}/event/queued-broadcast.
|
|
89
|
-
*/
|
|
90
|
-
export const fireQueuedBroadcast = async (req: Request, res: Response) => {
|
|
91
|
-
const body = req.body;
|
|
92
|
-
|
|
93
|
-
await dispatchEvent("{{module}}.created", body, {
|
|
94
|
-
queue: "default",
|
|
95
|
-
broadcast: { all: true }
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
res.status(202).json({ message: "Queued broadcast event dispatched", data: body });
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Why: Demonstrates broadcast-only realtime events.
|
|
103
|
-
* When: Use for transient notifications with no worker processing.
|
|
104
|
-
* Where: POST /{{module}}/event/broadcast.
|
|
105
|
-
*/
|
|
106
|
-
export const fireBroadcast = async (_req: Request, res: Response) => {
|
|
107
|
-
const payload = {
|
|
108
|
-
message: "Examples broadcast fired",
|
|
109
|
-
firedAt: new Date().toISOString()
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
await dispatchEvent("{{module}}.realtime", payload, { broadcast: { all: true } });
|
|
113
|
-
|
|
114
|
-
res.json({ message: "Broadcast event dispatched", data: payload });
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Why: Demonstrates multipart upload with storage facade.
|
|
119
|
-
* When: Use when API accepts binary files from forms/clients.
|
|
120
|
-
* Where: POST /{{module}}/upload.
|
|
121
|
-
*/
|
|
122
|
-
export const upload = async (req: Request, res: Response) => {
|
|
123
|
-
const file = (req.body as any)?.file;
|
|
124
|
-
|
|
125
|
-
if (!(file instanceof File)) {
|
|
126
|
-
return res.status(422).json({ message: "File is required" });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Writes to public disk. Name is auto-generated when omitted.
|
|
130
|
-
const path = await storage.disk("public").putFile("uploads", file);
|
|
131
|
-
|
|
132
|
-
res.json({
|
|
133
|
-
message: "File uploaded successfully",
|
|
134
|
-
path,
|
|
135
|
-
url: storage.disk("public").url(path)
|
|
136
|
-
});
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Why: Demonstrates Excel import by reading uploaded workbook rows.
|
|
141
|
-
* When: Use when clients submit .xlsx files for server-side processing.
|
|
142
|
-
* Where: POST /{{module}}/import/excel.
|
|
143
|
-
*/
|
|
144
|
-
export const importExcel = async (req: Request, res: Response) => {
|
|
145
|
-
let ExcelJS: any;
|
|
146
|
-
try {
|
|
147
|
-
({ default: ExcelJS } = await import("exceljs"));
|
|
148
|
-
} catch {
|
|
149
|
-
return res.status(500).json({ message: "Missing optional dependency 'exceljs'. Install with: bun add exceljs" });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const file = (req.body as any)?.file;
|
|
153
|
-
if (!(file instanceof File)) {
|
|
154
|
-
return res.status(422).json({ message: "File is required" });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const buffer = Buffer.from(await file.arrayBuffer());
|
|
158
|
-
const workbook = new ExcelJS.Workbook();
|
|
159
|
-
await workbook.xlsx.load(buffer);
|
|
160
|
-
const sheet = workbook.worksheets[0];
|
|
161
|
-
if (!sheet) return res.status(422).json({ message: "Workbook has no sheets" });
|
|
162
|
-
|
|
163
|
-
const headerRow = sheet.getRow(1);
|
|
164
|
-
const headers = headerRow.values.slice(1).map((value: unknown) => String(value || "").trim());
|
|
165
|
-
const rows: Record<string, string | number | null>[] = [];
|
|
166
|
-
sheet.eachRow((row: any, rowNumber: number) => {
|
|
167
|
-
if (rowNumber === 1) return;
|
|
168
|
-
const values = row.values.slice(1);
|
|
169
|
-
const record: Record<string, string | number | null> = {};
|
|
170
|
-
for (let i = 0; i < headers.length; i++) {
|
|
171
|
-
const value = values[i];
|
|
172
|
-
record[headers[i] || `column_${i + 1}`] = value == null ? null : (typeof value === "object" && "text" in value ? String(value.text) : value);
|
|
173
|
-
}
|
|
174
|
-
rows.push(record);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
res.json({
|
|
178
|
-
message: "Excel imported",
|
|
179
|
-
data: {
|
|
180
|
-
sheetName: sheet.name,
|
|
181
|
-
headers,
|
|
182
|
-
totalRows: rows.length,
|
|
183
|
-
preview: rows.slice(0, 20)
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Why: Demonstrates short-lived generated downloads with one-time token consume.
|
|
190
|
-
* When: Use for temporary export artifacts that should not persist.
|
|
191
|
-
* Where: POST /{{module}}/download/generate.
|
|
192
|
-
*/
|
|
193
|
-
export const generateDownload = async (_req: Request, res: Response) => {
|
|
194
|
-
const csv = ["id,title", "1,nexwire report", "2,generated and temporary"].join("\n");
|
|
195
|
-
const token = await storage.generateForDownload({
|
|
196
|
-
prefix: "{{module}}-report",
|
|
197
|
-
extension: "csv",
|
|
198
|
-
data: csv
|
|
199
|
-
});
|
|
200
|
-
res.json({
|
|
201
|
-
message: "Temporary file generated",
|
|
202
|
-
token,
|
|
203
|
-
downloadUrl: `/{{module}}/download/${encodeURIComponent(token)}`
|
|
204
|
-
});
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Why: Consumes a temporary token and returns the generated CSV file.
|
|
209
|
-
* When: Called after generateDownload to perform one-time download.
|
|
210
|
-
* Where: GET /{{module}}/download/{*token}.
|
|
211
|
-
*/
|
|
212
|
-
export const downloadGenerated = async (req: Request, res: Response) => {
|
|
213
|
-
const token = decodeURIComponent(req.params.token);
|
|
214
|
-
try {
|
|
215
|
-
const file = await storage.consumeGenerated(token);
|
|
216
|
-
res.type("text/csv").attachment("{{module}}-report.csv").send(file);
|
|
217
|
-
} catch {
|
|
218
|
-
res.status(404).json({ message: "Download token expired or invalid" });
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Why: Demonstrates styled Excel export generation and temporary delivery.
|
|
224
|
-
* When: Use when spreadsheet output with formatting/totals is needed.
|
|
225
|
-
* Where: POST /{{module}}/download/excel/generate.
|
|
226
|
-
*/
|
|
227
|
-
export const generateExcelDownload = async (req: Request, res: Response) => {
|
|
228
|
-
let ExcelJS: any;
|
|
229
|
-
try {
|
|
230
|
-
({ default: ExcelJS } = await import("exceljs"));
|
|
231
|
-
} catch {
|
|
232
|
-
return res.status(500).json({ message: "Missing optional dependency 'exceljs'. Install with: bun add exceljs" });
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const title = (req.body as { title?: string })?.title ?? "";
|
|
236
|
-
// remember(key, ttl, factory) caches expensive work for repeated requests.
|
|
237
|
-
const rows = await cache.remember("{{module}}:index", 60, async () => [
|
|
238
|
-
{ id: 1, title: "nexwire framework ready" },
|
|
239
|
-
{ id: 2, title: "modules are auto-discovered" }
|
|
240
|
-
]);
|
|
241
|
-
|
|
242
|
-
const workbook = new ExcelJS.Workbook();
|
|
243
|
-
const sheet = workbook.addWorksheet("Examples");
|
|
244
|
-
sheet.mergeCells("A1:C1");
|
|
245
|
-
sheet.getCell("A1").value = title;
|
|
246
|
-
sheet.getCell("A1").font = { bold: true, size: 14, color: { argb: "FFFFFFFF" } };
|
|
247
|
-
sheet.getCell("A1").alignment = { horizontal: "center", vertical: "middle" };
|
|
248
|
-
sheet.getCell("A1").fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4E78" } };
|
|
249
|
-
sheet.addRow(["ID", "Title", "Length"]);
|
|
250
|
-
sheet.getRow(2).font = { bold: true, color: { argb: "FFFFFFFF" } };
|
|
251
|
-
sheet.getRow(2).alignment = { horizontal: "center" };
|
|
252
|
-
sheet.getRow(2).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF2E75B6" } };
|
|
253
|
-
for (const row of rows) sheet.addRow([row.id, row.title, row.title.length]);
|
|
254
|
-
|
|
255
|
-
const dataStart = 3;
|
|
256
|
-
const dataEnd = dataStart + rows.length - 1;
|
|
257
|
-
const subtotalRow = dataEnd + 1;
|
|
258
|
-
const grandTotalRow = subtotalRow + 1;
|
|
259
|
-
sheet.getCell(`A${subtotalRow}`).value = "Subtotal";
|
|
260
|
-
sheet.getCell(`B${subtotalRow}`).value = rows.length;
|
|
261
|
-
sheet.getCell(`C${subtotalRow}`).value = { formula: `SUM(C${dataStart}:C${dataEnd})` };
|
|
262
|
-
sheet.getCell(`A${grandTotalRow}`).value = "Grand Total";
|
|
263
|
-
sheet.getCell(`B${grandTotalRow}`).value = rows.length;
|
|
264
|
-
sheet.getCell(`C${grandTotalRow}`).value = { formula: `SUM(C${dataStart}:C${dataEnd})` };
|
|
265
|
-
for (const rowNumber of [subtotalRow, grandTotalRow]) {
|
|
266
|
-
sheet.getRow(rowNumber).font = { bold: true };
|
|
267
|
-
sheet.getRow(rowNumber).fill = { type: "pattern", pattern: "solid", fgColor: { argb: rowNumber === grandTotalRow ? "FFFCE4D6" : "FFE2EFDA" } };
|
|
268
|
-
}
|
|
269
|
-
sheet.columns = [{ width: 10 }, { width: 48 }, { width: 14 }];
|
|
270
|
-
sheet.views = [{ state: "frozen", ySplit: 2 }];
|
|
271
|
-
|
|
272
|
-
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
273
|
-
const token = await storage.generateForDownload({
|
|
274
|
-
prefix: "{{module}}-report",
|
|
275
|
-
extension: "xlsx",
|
|
276
|
-
data: buffer
|
|
277
|
-
});
|
|
278
|
-
res.json({
|
|
279
|
-
message: "Excel generated",
|
|
280
|
-
token,
|
|
281
|
-
downloadUrl: `/{{module}}/download/excel/${encodeURIComponent(token)}`
|
|
282
|
-
});
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Why: Consumes a temporary token and returns the generated XLSX file.
|
|
287
|
-
* When: Called after generateExcelDownload for one-time Excel download.
|
|
288
|
-
* Where: GET /{{module}}/download/excel/{*token}.
|
|
289
|
-
*/
|
|
290
|
-
export const downloadGeneratedExcel = async (req: Request, res: Response) => {
|
|
291
|
-
const token = decodeURIComponent(req.params.token);
|
|
292
|
-
try {
|
|
293
|
-
const file = await storage.consumeGenerated(token);
|
|
294
|
-
res.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet").attachment("{{module}}-report.xlsx").send(file);
|
|
295
|
-
} catch {
|
|
296
|
-
res.status(404).json({ message: "Download token expired or invalid" });
|
|
297
|
-
}
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Why: Starts hybrid PDF generation for heavy design + large data.
|
|
302
|
-
* When: Use when reports should be rendered with HTML/CSS and processed in background.
|
|
303
|
-
* Where: POST /{{module}}/download/pdf/generate.
|
|
304
|
-
*/
|
|
305
|
-
export const generatePdfDownload = async (req: Request, res: Response) => {
|
|
306
|
-
const body = req.body as PdfRequest;
|
|
307
|
-
const requestId = crypto.randomUUID();
|
|
308
|
-
|
|
309
|
-
// Queue mode path: store status in cache, then worker updates it.
|
|
310
|
-
if (cache.isAvailable()) {
|
|
311
|
-
await cache.put(pdfStatusKey(requestId), {
|
|
312
|
-
state: "pending",
|
|
313
|
-
createdAt: new Date().toISOString(),
|
|
314
|
-
mode: "queued"
|
|
315
|
-
}, 1800);
|
|
316
|
-
await dispatchCommand("{{module}}.pdf.generate", { requestId, ...body }, {
|
|
317
|
-
async: true,
|
|
318
|
-
queue: "default"
|
|
319
|
-
});
|
|
320
|
-
return res.status(202).json({
|
|
321
|
-
message: "PDF generation queued",
|
|
322
|
-
requestId,
|
|
323
|
-
mode: "queued",
|
|
324
|
-
statusUrl: `/{{module}}/download/pdf/${requestId}`,
|
|
325
|
-
downloadUrl: `/{{module}}/download/pdf/${requestId}`
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const token = await generatePdfToken(body);
|
|
330
|
-
res.status(202).json({
|
|
331
|
-
message: "PDF generated",
|
|
332
|
-
requestId,
|
|
333
|
-
mode: "inline",
|
|
334
|
-
statusUrl: `/{{module}}/download/pdf/${requestId}`,
|
|
335
|
-
downloadUrl: `/{{module}}/download/pdf/${encodeURIComponent(token)}`
|
|
336
|
-
});
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Why: Polls queued PDF status, then serves one-time PDF download when ready.
|
|
341
|
-
* When: Use after generatePdfDownload returns queued request id.
|
|
342
|
-
* Where: GET /{{module}}/download/pdf/{requestId}.
|
|
343
|
-
*/
|
|
344
|
-
export const downloadGeneratedPdf = async (req: Request, res: Response) => {
|
|
345
|
-
const requestId = decodeURIComponent(req.params.requestId);
|
|
346
|
-
if (cache.isAvailable()) {
|
|
347
|
-
// Status API contract:
|
|
348
|
-
// - pending -> return 202
|
|
349
|
-
// - ready + token -> return file
|
|
350
|
-
// - failed -> return 500 with message
|
|
351
|
-
const status = await cache.get<{ state: string; token?: string; message?: string }>(pdfStatusKey(requestId));
|
|
352
|
-
|
|
353
|
-
if (!status) return res.status(404).json({ message: "Unknown PDF request id" });
|
|
354
|
-
if (status.state === "pending") return res.status(202).json({ message: "PDF is still generating", state: "pending" });
|
|
355
|
-
if (status.state === "failed") return res.status(500).json({ message: status.message || "PDF generation failed" });
|
|
356
|
-
|
|
357
|
-
if (status.token) {
|
|
358
|
-
try {
|
|
359
|
-
const file = await storage.consumeGenerated(status.token);
|
|
360
|
-
await cache.forget(pdfStatusKey(requestId));
|
|
361
|
-
res.type("application/pdf").attachment("{{module}}-report.pdf").send(file);
|
|
362
|
-
return;
|
|
363
|
-
} catch {
|
|
364
|
-
return res.status(404).json({ message: "PDF token expired or invalid" });
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
const file = await storage.consumeGenerated(requestId);
|
|
371
|
-
res.type("application/pdf").attachment("{{module}}-report.pdf").send(file);
|
|
372
|
-
} catch {
|
|
373
|
-
res.status(404).json({ message: "PDF token expired or invalid" });
|
|
374
|
-
}
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
export async function generatePdfToken(payload: PdfRequest) {
|
|
378
|
-
let chromium: any;
|
|
379
|
-
try {
|
|
380
|
-
({ chromium } = await import("playwright"));
|
|
381
|
-
} catch {
|
|
382
|
-
throw new Error("Missing optional dependency 'playwright'. Install with: bun add playwright and run: bunx playwright install chromium");
|
|
383
|
-
}
|
|
384
|
-
const browser = await chromium.launch({ headless: true });
|
|
385
|
-
try {
|
|
386
|
-
const page = await browser.newPage();
|
|
387
|
-
await page.setContent(buildPdfHtml(payload), { waitUntil: "networkidle" });
|
|
388
|
-
const pdf = await page.pdf({
|
|
389
|
-
format: "A4",
|
|
390
|
-
printBackground: true,
|
|
391
|
-
margin: { top: "16mm", right: "12mm", bottom: "16mm", left: "12mm" }
|
|
392
|
-
});
|
|
393
|
-
return storage.generateForDownload({
|
|
394
|
-
prefix: "{{module}}-report",
|
|
395
|
-
extension: "pdf",
|
|
396
|
-
data: pdf
|
|
397
|
-
});
|
|
398
|
-
} finally {
|
|
399
|
-
await browser.close();
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function buildPdfHtml(payload: PdfRequest) {
|
|
404
|
-
const rows = Array.from({ length: payload.rows }, (_, i) => ({
|
|
405
|
-
id: i + 1,
|
|
406
|
-
label: `Record ${i + 1}`,
|
|
407
|
-
value: ((i + 1) * 17) % 1000
|
|
408
|
-
}));
|
|
409
|
-
const chunks: typeof rows[] = [];
|
|
410
|
-
for (let i = 0; i < rows.length; i += payload.chunkSize) {
|
|
411
|
-
chunks.push(rows.slice(i, i + payload.chunkSize));
|
|
412
|
-
}
|
|
413
|
-
const sections = chunks.map((chunk, index) => {
|
|
414
|
-
const tableRows = chunk.map(item => `<tr><td>${item.id}</td><td>${item.label}</td><td>${item.value}</td></tr>`).join("");
|
|
415
|
-
return `<section class="page"><h2>Batch ${index + 1}</h2><table><thead><tr><th>ID</th><th>Label</th><th>Value</th></tr></thead><tbody>${tableRows}</tbody></table></section>`;
|
|
416
|
-
}).join("");
|
|
417
|
-
return `<!doctype html><html><head><meta charset="utf-8" /><style>body{font-family:Arial,sans-serif;color:#1f2937;margin:0}.hero{padding:24px;background:linear-gradient(120deg,#0f766e,#1d4ed8);color:#fff}.hero h1{margin:0 0 4px 0;font-size:22px}.hero p{margin:0;font-size:12px;opacity:.9}.page{padding:16px 20px;page-break-after:always}.page:last-child{page-break-after:auto}h2{margin:0 0 10px 0;font-size:14px;color:#0f172a}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #cbd5e1;padding:6px}th{background:#e2e8f0;text-align:left}tr:nth-child(even){background:#f8fafc}</style></head><body><div class="hero"><h1>${payload.title}</h1><p>Rows: ${payload.rows} | Chunk size: ${payload.chunkSize}</p></div>${sections}</body></html>`;
|
|
418
|
-
}
|