toiljs 0.0.104 → 0.0.106
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/CHANGELOG.md +10 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.d.ts +28 -0
- package/build/compiler/auth-emails.js +165 -0
- package/build/compiler/emails.d.ts +2 -0
- package/build/compiler/emails.js +4 -1
- package/build/compiler/index.js +12 -0
- package/build/compiler/toil-docs.generated.js +7 -6
- package/docs/README.md +2 -1
- package/docs/auth/README.md +2 -0
- package/docs/auth/emails.md +91 -0
- package/docs/introduction/README.md +19 -10
- package/docs/introduction/hyperscale.md +1 -1
- package/docs/introduction/vs-other-frameworks.md +3 -3
- package/docs/introduction/why-toil.md +2 -4
- package/docs/llms.txt +1 -0
- package/package.json +1 -1
- package/server/auth/AuthController.ts +16 -40
- package/server/globals/email.ts +15 -2
- package/src/compiler/auth-emails.ts +279 -0
- package/src/compiler/emails.ts +14 -1
- package/src/compiler/index.ts +24 -1
- package/src/compiler/toil-docs.generated.ts +7 -6
- package/test/auth-emails.test.ts +115 -0
- package/test/email-console.test.ts +2 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { createServer } from 'vite';
|
|
6
|
+
import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal } from './emails.js';
|
|
7
|
+
import { createViteConfig } from './vite.js';
|
|
8
|
+
function globalsDir(root) {
|
|
9
|
+
const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
|
|
10
|
+
if (fs.existsSync(inApp))
|
|
11
|
+
return inApp;
|
|
12
|
+
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
13
|
+
return path.join(pkgDir, 'server', 'globals');
|
|
14
|
+
}
|
|
15
|
+
const SPECS = {
|
|
16
|
+
AuthConfirm: {
|
|
17
|
+
fn: 'confirm',
|
|
18
|
+
token: 'link',
|
|
19
|
+
purpose: 'verify',
|
|
20
|
+
subjectFromArg: false,
|
|
21
|
+
defaultSubject: 'Confirm your account',
|
|
22
|
+
defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
|
|
23
|
+
defaultHtml: '<p>Confirm your account by clicking the link below:</p>' +
|
|
24
|
+
'<p><a href="{{link}}">Confirm my account</a></p>' +
|
|
25
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
26
|
+
},
|
|
27
|
+
AuthReset: {
|
|
28
|
+
fn: 'reset',
|
|
29
|
+
token: 'link',
|
|
30
|
+
purpose: 'reset',
|
|
31
|
+
subjectFromArg: false,
|
|
32
|
+
defaultSubject: 'Reset your password',
|
|
33
|
+
defaultText: 'Reset your password by opening this link:\n{{link}}\n',
|
|
34
|
+
defaultHtml: '<p>We received a request to reset your password. Click the link below:</p>' +
|
|
35
|
+
'<p><a href="{{link}}">Reset my password</a></p>' +
|
|
36
|
+
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
37
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
38
|
+
},
|
|
39
|
+
Auth2fa: {
|
|
40
|
+
fn: 'twofa',
|
|
41
|
+
token: 'code',
|
|
42
|
+
purpose: '2fa',
|
|
43
|
+
subjectFromArg: true,
|
|
44
|
+
defaultSubject: '',
|
|
45
|
+
defaultText: 'Your verification code is {{code}}.\n' +
|
|
46
|
+
'It expires in a few minutes. If you did not request it, ignore this email.\n',
|
|
47
|
+
defaultHtml: '<p>Your verification code is:</p>' +
|
|
48
|
+
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
|
|
49
|
+
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const AUTH_NAMES = Object.keys(SPECS);
|
|
53
|
+
const GENERATED_BASENAME = '_auth_emails.ts';
|
|
54
|
+
function warn(msg) {
|
|
55
|
+
process.stderr.write(` toil: auth emails ${msg}\n`);
|
|
56
|
+
}
|
|
57
|
+
function asLit(s) {
|
|
58
|
+
return JSON.stringify(s);
|
|
59
|
+
}
|
|
60
|
+
function reservedFilesIn(emailsDir) {
|
|
61
|
+
const out = new Map();
|
|
62
|
+
if (!fs.existsSync(emailsDir))
|
|
63
|
+
return out;
|
|
64
|
+
for (const f of fs.readdirSync(emailsDir).sort()) {
|
|
65
|
+
if (!/\.(tsx|jsx)$/.test(f))
|
|
66
|
+
continue;
|
|
67
|
+
const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
|
|
68
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name))
|
|
69
|
+
out.set(name, f);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function effectiveFromOverride(name, spec, r) {
|
|
74
|
+
if (!r.tokens.includes(spec.token))
|
|
75
|
+
warn(`override emails/${name} never uses the {${spec.token}} prop, so the ` +
|
|
76
|
+
`${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`);
|
|
77
|
+
for (const t of r.tokens)
|
|
78
|
+
if (t !== spec.token)
|
|
79
|
+
warn(`override emails/${name} uses {${t}}, which auth does not provide ` +
|
|
80
|
+
`(only {${spec.token}}); it will render empty.`);
|
|
81
|
+
const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
|
|
82
|
+
return { subject, text: r.text, html: r.html };
|
|
83
|
+
}
|
|
84
|
+
function emitFn(spec, eff) {
|
|
85
|
+
const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
|
|
86
|
+
const sig = spec.fn === 'twofa'
|
|
87
|
+
? 'twofa(to: string, code: string, subject: string): void'
|
|
88
|
+
: `${spec.fn}(to: string, ${spec.token}: string): void`;
|
|
89
|
+
return [
|
|
90
|
+
` export function ${sig} {`,
|
|
91
|
+
` const T: string = ${asLit(eff.text)};`,
|
|
92
|
+
` const H: string = ${asLit(eff.html)};`,
|
|
93
|
+
` const v = new Map<string, string>();`,
|
|
94
|
+
` v.set(${asLit(spec.token)}, ${spec.token});`,
|
|
95
|
+
` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
|
|
96
|
+
` }`,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
function moduleSource(parts) {
|
|
100
|
+
const out = [
|
|
101
|
+
'// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
|
|
102
|
+
'// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
|
|
103
|
+
'// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
|
|
104
|
+
'// global (server/globals/email.ts); the detached send keeps auth constant-time.',
|
|
105
|
+
'',
|
|
106
|
+
'export namespace AuthEmail {',
|
|
107
|
+
];
|
|
108
|
+
for (const name of AUTH_NAMES)
|
|
109
|
+
out.push(...emitFn(SPECS[name], parts[name]));
|
|
110
|
+
out.push('}');
|
|
111
|
+
return out.join('\n') + '\n';
|
|
112
|
+
}
|
|
113
|
+
export async function renderAuthEmails(cfg, authOn) {
|
|
114
|
+
const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
|
|
115
|
+
if (!authOn) {
|
|
116
|
+
if (fs.existsSync(generatedPath))
|
|
117
|
+
fs.rmSync(generatedPath);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const emailsDir = path.join(cfg.root, 'emails');
|
|
121
|
+
const overrides = reservedFilesIn(emailsDir);
|
|
122
|
+
const parts = {};
|
|
123
|
+
for (const name of AUTH_NAMES) {
|
|
124
|
+
const spec = SPECS[name];
|
|
125
|
+
parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
|
|
126
|
+
}
|
|
127
|
+
if (overrides.size > 0) {
|
|
128
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
129
|
+
const server = await createServer({
|
|
130
|
+
...(await createViteConfig(cfg)),
|
|
131
|
+
server: { middlewareMode: true, hmr: false },
|
|
132
|
+
appType: 'custom',
|
|
133
|
+
logLevel: 'silent',
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
for (const [name, file] of overrides) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
|
|
139
|
+
if (r)
|
|
140
|
+
parts[name] = effectiveFromOverride(name, SPECS[name], r);
|
|
141
|
+
else
|
|
142
|
+
warn(`skipped ${file} (no default-exported component)`);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await server.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const next = moduleSource(parts);
|
|
154
|
+
const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
|
|
155
|
+
if (prev === next)
|
|
156
|
+
return;
|
|
157
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
158
|
+
fs.writeFileSync(generatedPath, next);
|
|
159
|
+
if (overrides.size > 0)
|
|
160
|
+
process.stdout.write(pc.green(' ✓ ') +
|
|
161
|
+
pc.dim(`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`) +
|
|
162
|
+
'\n');
|
|
163
|
+
}
|
|
164
|
+
export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
|
|
165
|
+
export const __test = { moduleSource, effectiveFromOverride, SPECS };
|
|
@@ -14,12 +14,14 @@ export interface RenderedEmail {
|
|
|
14
14
|
tokens: string[];
|
|
15
15
|
purpose: string;
|
|
16
16
|
}
|
|
17
|
+
export declare const RESERVED_AUTH_EMAIL_NAMES: Set<string>;
|
|
17
18
|
declare function tokenProps(seen: Set<string>): Record<string, unknown>;
|
|
18
19
|
declare function htmlToText(html: string): string;
|
|
19
20
|
declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string, css?: string): Promise<RenderedEmail | null>;
|
|
20
21
|
export declare function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string>;
|
|
21
22
|
export declare function renderEmailFile(server: ViteDevServer, emailsDir: string, file: string, render: (el: unknown) => string): Promise<RenderedEmail | null>;
|
|
22
23
|
export declare function toPascal(base: string): string;
|
|
24
|
+
export declare function serverDir(root: string): string;
|
|
23
25
|
declare function renderModuleSource(rendered: RenderedEmail[]): string;
|
|
24
26
|
export declare function renderEmails(cfg: ResolvedToilConfig): Promise<void>;
|
|
25
27
|
export declare const __test: {
|
package/build/compiler/emails.js
CHANGED
|
@@ -15,6 +15,7 @@ const REACT_INTERNAL = new Set([
|
|
|
15
15
|
'constructor',
|
|
16
16
|
'then',
|
|
17
17
|
]);
|
|
18
|
+
export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
|
|
18
19
|
const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
|
|
19
20
|
function extractTokens(s) {
|
|
20
21
|
const out = [];
|
|
@@ -133,7 +134,7 @@ export function toPascal(base) {
|
|
|
133
134
|
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
134
135
|
.join('');
|
|
135
136
|
}
|
|
136
|
-
function serverDir(root) {
|
|
137
|
+
export function serverDir(root) {
|
|
137
138
|
try {
|
|
138
139
|
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'));
|
|
139
140
|
const first = Array.isArray(cfg.entries)
|
|
@@ -216,6 +217,8 @@ export async function renderEmails(cfg) {
|
|
|
216
217
|
const rendered = [];
|
|
217
218
|
try {
|
|
218
219
|
for (const file of files) {
|
|
220
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
|
|
221
|
+
continue;
|
|
219
222
|
try {
|
|
220
223
|
const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
|
|
221
224
|
if (r)
|
package/build/compiler/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import pc from 'picocolors';
|
|
|
8
8
|
import { build as viteBuild, createServer, mergeConfig } from 'vite';
|
|
9
9
|
import { loadConfig } from './config.js';
|
|
10
10
|
import { renderEmails } from './emails.js';
|
|
11
|
+
import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
|
|
11
12
|
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
12
13
|
import { prerenderStaticParams } from './ssg.js';
|
|
13
14
|
import { extractDevSsrTemplates, extractServerSlots, extractTemplates, } from './template-build.js';
|
|
@@ -118,6 +119,13 @@ function serverImportsAuth(root, files) {
|
|
|
118
119
|
}
|
|
119
120
|
return false;
|
|
120
121
|
}
|
|
122
|
+
function serverAuthEnabled(root, configAuth) {
|
|
123
|
+
if (configAuth)
|
|
124
|
+
return true;
|
|
125
|
+
if (!fs.existsSync(path.join(root, 'toilconfig.json')))
|
|
126
|
+
return false;
|
|
127
|
+
return serverImportsAuth(root, serverEntryFiles(root));
|
|
128
|
+
}
|
|
121
129
|
export async function buildServer(root, auth = false) {
|
|
122
130
|
if (!fs.existsSync(path.join(root, 'toilconfig.json')))
|
|
123
131
|
return;
|
|
@@ -361,6 +369,7 @@ function watchServer(cfg, watcher) {
|
|
|
361
369
|
building = true;
|
|
362
370
|
process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
|
|
363
371
|
renderEmails(cfg)
|
|
372
|
+
.then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
|
|
364
373
|
.then(() => buildServer(root, cfg.auth))
|
|
365
374
|
.then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
|
|
366
375
|
.catch((e) => process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'))
|
|
@@ -376,6 +385,7 @@ function watchServer(cfg, watcher) {
|
|
|
376
385
|
const isServerSource = (file) => file.endsWith('.ts') &&
|
|
377
386
|
!file.endsWith('.d.ts') &&
|
|
378
387
|
path.basename(file) !== '_emails.ts' &&
|
|
388
|
+
path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
|
|
379
389
|
dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
|
|
380
390
|
const isEmailSource = (file) => /\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
|
|
381
391
|
watcher.on('error', (err) => {
|
|
@@ -552,6 +562,7 @@ export async function dev(opts = {}) {
|
|
|
552
562
|
if (hasServer)
|
|
553
563
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
554
564
|
await renderEmails(cfg);
|
|
565
|
+
await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
|
|
555
566
|
generate(cfg);
|
|
556
567
|
if (hasServer)
|
|
557
568
|
await extractServerSlots(cfg);
|
|
@@ -636,6 +647,7 @@ export async function build(opts = {}) {
|
|
|
636
647
|
if (hasServer && !opts.serverOnly)
|
|
637
648
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
638
649
|
await renderEmails(cfg);
|
|
650
|
+
await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
|
|
639
651
|
generate(cfg);
|
|
640
652
|
const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map();
|
|
641
653
|
await buildServer(cfg.root, cfg.auth);
|