toiljs 0.0.38 → 0.0.40
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 +15 -1
- package/as-pect.config.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/emails.d.ts +29 -0
- package/build/compiler/emails.js +201 -0
- package/build/compiler/index.js +12 -5
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/envelope.d.ts +1 -0
- package/build/devserver/host.d.ts +1 -0
- package/build/devserver/host.js +40 -1
- package/build/devserver/index.js +3 -0
- package/build/devserver/module.js +3 -0
- package/build/devserver/ratelimit.d.ts +8 -0
- package/build/devserver/ratelimit.js +64 -0
- package/docs/README.md +6 -0
- package/docs/email.md +273 -0
- package/docs/ratelimit.md +95 -0
- package/examples/basic/client/components/Header.tsx +1 -1
- package/examples/basic/client/routes/index.tsx +1 -1
- package/package.json +3 -2
- package/server/globals/email.ts +188 -0
- package/server/globals/ratelimit.ts +89 -0
- package/server/globals/twofactor.ts +212 -0
- package/server/runtime/env/Server.ts +1 -2
- package/server/runtime/handlers/ToilHandler.ts +4 -5
- package/server/runtime/index.ts +1 -2
- package/server/runtime/lang/Potential.ts +2 -2
- package/server/runtime/rest/RouteContext.ts +22 -0
- package/src/compiler/emails.ts +304 -0
- package/src/compiler/index.ts +18 -6
- package/src/devserver/envelope.ts +3 -0
- package/src/devserver/host.ts +66 -1
- package/src/devserver/index.ts +7 -0
- package/src/devserver/module.ts +3 -0
- package/src/devserver/ratelimit.ts +116 -0
- package/test/assembly/cookie.spec.ts +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time email pipeline: compile `emails/*.tsx` React components into a
|
|
3
|
+
* generated AssemblyScript module the server WASM uses to send mail.
|
|
4
|
+
*
|
|
5
|
+
* Why build-time: email clients run NO JavaScript and strip `<style>`/external
|
|
6
|
+
* CSS, and the edge server is WASM (no React at send time). So each email is
|
|
7
|
+
* rendered to STATIC, inline-CSS HTML once at build, with `{{token}}` holes
|
|
8
|
+
* where component props were read; the edge fills those holes per send via the
|
|
9
|
+
* `EmailTemplate` global (see `server/globals/email.ts`) and calls `email_send`.
|
|
10
|
+
*
|
|
11
|
+
* Per-send data is therefore FIELD SUBSTITUTION only (`{{name}}`, `{{code}}`) --
|
|
12
|
+
* a build-time `{items.map(...)}` or conditional bakes into the output, it does
|
|
13
|
+
* not re-run per send. Plenty for transactional / 2FA / confirmation email.
|
|
14
|
+
*
|
|
15
|
+
* The dynamic fields are discovered without parsing types: the component is
|
|
16
|
+
* rendered with a Proxy whose every prop read returns the literal `{{prop}}`
|
|
17
|
+
* and records the name, so `({name}) => <h1>Hi {name}` renders `Hi {{name}}`
|
|
18
|
+
* and we learn the field is `name`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { createServer } from 'vite';
|
|
25
|
+
|
|
26
|
+
import type { ResolvedToilConfig } from './config.js';
|
|
27
|
+
import { createViteConfig } from './vite.js';
|
|
28
|
+
|
|
29
|
+
/** What an `emails/*.tsx` module may export. `default` is the React component. */
|
|
30
|
+
interface EmailModule {
|
|
31
|
+
default: unknown;
|
|
32
|
+
/** Subject line, a token template (e.g. `'Welcome, {{name}}'`). Defaults to the name. */
|
|
33
|
+
subject?: unknown;
|
|
34
|
+
/** Optional plain-text body template; derived by stripping the HTML when absent. */
|
|
35
|
+
text?: unknown;
|
|
36
|
+
/** Optional dedup/abuse `purpose` tag; defaults to the email name lowercased. */
|
|
37
|
+
purpose?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One email rendered to its baked, token-holed parts. */
|
|
41
|
+
interface RenderedEmail {
|
|
42
|
+
name: string;
|
|
43
|
+
subject: string;
|
|
44
|
+
html: string;
|
|
45
|
+
text: string;
|
|
46
|
+
/** Sorted, unique `{{token}}` field names this email interpolates. */
|
|
47
|
+
tokens: string[];
|
|
48
|
+
purpose: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keys React (or the renderer) reads on props that must never become tokens. */
|
|
52
|
+
const REACT_INTERNAL = new Set([
|
|
53
|
+
'children',
|
|
54
|
+
'key',
|
|
55
|
+
'ref',
|
|
56
|
+
'$$typeof',
|
|
57
|
+
'__self',
|
|
58
|
+
'__source',
|
|
59
|
+
'toJSON',
|
|
60
|
+
'prototype',
|
|
61
|
+
'constructor',
|
|
62
|
+
'then', // so a thenable check never tokenizes
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
|
|
66
|
+
|
|
67
|
+
function extractTokens(s: string): string[] {
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
let m: RegExpExecArray | null;
|
|
70
|
+
TOKEN_RE.lastIndex = 0;
|
|
71
|
+
while ((m = TOKEN_RE.exec(s)) !== null) out.push(m[1]!);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A props object whose every (non-internal) read returns the literal `{{key}}`
|
|
77
|
+
* and records `key`. Renders the component with placeholders and discovers the
|
|
78
|
+
* field names in one pass — no TS type parsing.
|
|
79
|
+
*/
|
|
80
|
+
function tokenProps(seen: Set<string>): Record<string, unknown> {
|
|
81
|
+
return new Proxy(
|
|
82
|
+
{},
|
|
83
|
+
{
|
|
84
|
+
get(_target, key): unknown {
|
|
85
|
+
if (typeof key !== 'string' || REACT_INTERNAL.has(key)) return undefined;
|
|
86
|
+
seen.add(key);
|
|
87
|
+
return `{{${key}}}`;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Inline `<style>`/CSS into element `style=""` (juice) so email clients honor it.
|
|
94
|
+
* Optional: without juice installed, inline `style={{}}` props still work. */
|
|
95
|
+
async function inlineCss(html: string): Promise<string> {
|
|
96
|
+
try {
|
|
97
|
+
// Variable specifier: keep `juice` an OPTIONAL dependency (tsc won't
|
|
98
|
+
// require its types, and a missing install falls back below).
|
|
99
|
+
const specifier = 'juice';
|
|
100
|
+
const mod = (await import(specifier)) as { default: (html: string) => string };
|
|
101
|
+
return mod.default(html);
|
|
102
|
+
} catch {
|
|
103
|
+
return html;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A crude HTML→text fallback for the plain-text part (better deliverability). */
|
|
108
|
+
function htmlToText(html: string): string {
|
|
109
|
+
return html
|
|
110
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
111
|
+
.replace(/<(?:br|\/p|\/h[1-6]|\/tr|\/div)\s*\/?>/gi, '\n')
|
|
112
|
+
.replace(/<[^>]+>/g, '')
|
|
113
|
+
.replace(/ /g, ' ')
|
|
114
|
+
.replace(/'|'/g, "'")
|
|
115
|
+
.replace(/"|"/g, '"')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
118
|
+
.replace(/&/g, '&') // last: so "&lt;" -> "<", not "<"
|
|
119
|
+
.replace(/[ \t]+/g, ' ')
|
|
120
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
121
|
+
.replace(/[ \t]*\n[ \t]*/g, '\n')
|
|
122
|
+
.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Render one loaded email module to its baked parts, or `null` if it has no
|
|
126
|
+
* component. The default export must be a FUNCTION component (not a class) of
|
|
127
|
+
* its props: we call it directly with the token Proxy and render the element
|
|
128
|
+
* tree it returns. Going through `createElement(Component, proxy)` would not
|
|
129
|
+
* work -- React copies the config into a plain props object, so the component
|
|
130
|
+
* would never see the Proxy and every field would render empty. */
|
|
131
|
+
async function renderModule(
|
|
132
|
+
name: string,
|
|
133
|
+
mod: EmailModule,
|
|
134
|
+
render: (el: unknown) => string,
|
|
135
|
+
): Promise<RenderedEmail | null> {
|
|
136
|
+
if (typeof mod.default !== 'function') return null;
|
|
137
|
+
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
const component = mod.default as (props: unknown) => unknown;
|
|
140
|
+
let html = render(component(tokenProps(seen)));
|
|
141
|
+
html = await inlineCss(html);
|
|
142
|
+
|
|
143
|
+
const subject = typeof mod.subject === 'string' ? mod.subject : name;
|
|
144
|
+
const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
|
|
145
|
+
const purpose =
|
|
146
|
+
typeof mod.purpose === 'string' && mod.purpose.length > 0
|
|
147
|
+
? mod.purpose
|
|
148
|
+
: name.toLowerCase();
|
|
149
|
+
|
|
150
|
+
// Union the proxy-observed fields with any literal {{token}} authored in the
|
|
151
|
+
// subject/text/html, so a hand-written placeholder is also a parameter.
|
|
152
|
+
const tokens = [
|
|
153
|
+
...new Set([
|
|
154
|
+
...seen,
|
|
155
|
+
...extractTokens(subject),
|
|
156
|
+
...extractTokens(html),
|
|
157
|
+
...extractTokens(text),
|
|
158
|
+
]),
|
|
159
|
+
].sort();
|
|
160
|
+
|
|
161
|
+
return { name, subject, html, text, tokens, purpose };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `welcome-email` / `welcome_email` -> `WelcomeEmail`. */
|
|
165
|
+
function toPascal(base: string): string {
|
|
166
|
+
return base
|
|
167
|
+
.split(/[-_\s.]+/)
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
170
|
+
.join('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Server source dir (where the generated module must live to be compiled): the
|
|
174
|
+
* dir of the first toilconfig entry, else `<root>/server`. */
|
|
175
|
+
function serverDir(root: string): string {
|
|
176
|
+
try {
|
|
177
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
178
|
+
entries?: unknown;
|
|
179
|
+
};
|
|
180
|
+
const first = Array.isArray(cfg.entries)
|
|
181
|
+
? cfg.entries.find((e): e is string => typeof e === 'string')
|
|
182
|
+
: undefined;
|
|
183
|
+
if (first) return path.dirname(path.resolve(root, first));
|
|
184
|
+
} catch {
|
|
185
|
+
// fall through to the default
|
|
186
|
+
}
|
|
187
|
+
return path.join(root, 'server');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A valid AS/JS string literal for `s` (double-quoted, fully escaped). */
|
|
191
|
+
function asLit(s: string): string {
|
|
192
|
+
return JSON.stringify(s);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A unique, valid AS parameter identifier for `token`, avoiding the fixed
|
|
196
|
+
* `to`/`purpose` params, AS keywords, and earlier tokens. */
|
|
197
|
+
function paramName(token: string, used: Set<string>): string {
|
|
198
|
+
const RESERVED = new Set(['to', 'purpose', 'class', 'function', 'new', 'this', 'type', 'in']);
|
|
199
|
+
let p = token;
|
|
200
|
+
while (used.has(p) || RESERVED.has(p)) p = p + '_';
|
|
201
|
+
used.add(p);
|
|
202
|
+
return p;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Codegen the `Emails` AssemblyScript module from the rendered set. */
|
|
206
|
+
function renderModuleSource(rendered: RenderedEmail[]): string {
|
|
207
|
+
const out: string[] = [];
|
|
208
|
+
out.push('// GENERATED by toiljs from emails/*.tsx -- DO NOT EDIT.');
|
|
209
|
+
out.push('// Each email is rendered to static, inline-CSS HTML at build time;');
|
|
210
|
+
out.push('// {{tokens}} are filled per send. `EmailTemplate`/`EmailStatus` are');
|
|
211
|
+
out.push('// toiljs globals (server/globals/email.ts), so no import is needed.');
|
|
212
|
+
out.push('');
|
|
213
|
+
out.push('export namespace Emails {');
|
|
214
|
+
for (const e of rendered) {
|
|
215
|
+
out.push(` export namespace ${e.name} {`);
|
|
216
|
+
out.push(` const SUBJECT: string = ${asLit(e.subject)};`);
|
|
217
|
+
out.push(` const TEXT: string = ${asLit(e.text)};`);
|
|
218
|
+
out.push(` const HTML: string = ${asLit(e.html)};`);
|
|
219
|
+
const used = new Set<string>();
|
|
220
|
+
const params = e.tokens.map((t) => ({ token: t, param: paramName(t, used) }));
|
|
221
|
+
const sig = ['to: string']
|
|
222
|
+
.concat(params.map((p) => `${p.param}: string`))
|
|
223
|
+
.concat(`purpose: string = ${asLit(e.purpose)}`)
|
|
224
|
+
.join(', ');
|
|
225
|
+
out.push(` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`);
|
|
226
|
+
out.push(` export function send(${sig}): EmailStatus {`);
|
|
227
|
+
out.push(` const __v = new Map<string, string>();`);
|
|
228
|
+
for (const p of params) out.push(` __v.set(${asLit(p.token)}, ${p.param});`);
|
|
229
|
+
out.push(` return new EmailTemplate(SUBJECT, TEXT, HTML).send(to, __v, purpose);`);
|
|
230
|
+
out.push(` }`);
|
|
231
|
+
out.push(` }`);
|
|
232
|
+
}
|
|
233
|
+
out.push('}');
|
|
234
|
+
return out.join('\n') + '\n';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const GENERATED_BASENAME = '_emails.ts';
|
|
238
|
+
|
|
239
|
+
function warn(msg: string): void {
|
|
240
|
+
process.stderr.write(` toil: emails ${msg}\n`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Render every `emails/*.tsx` and (re)write the generated `<server>/_emails.ts`
|
|
245
|
+
* module, BEFORE the toilscript server build so it is compiled in. A no-op when
|
|
246
|
+
* there is no `emails/` directory. Removes a stale generated module when the
|
|
247
|
+
* directory becomes empty.
|
|
248
|
+
*/
|
|
249
|
+
export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
250
|
+
const emailsDir = path.join(cfg.root, 'emails');
|
|
251
|
+
const generatedPath = path.join(serverDir(cfg.root), GENERATED_BASENAME);
|
|
252
|
+
|
|
253
|
+
if (!fs.existsSync(emailsDir)) return;
|
|
254
|
+
const files = fs
|
|
255
|
+
.readdirSync(emailsDir)
|
|
256
|
+
.filter((f) => /\.(tsx|jsx)$/.test(f))
|
|
257
|
+
.sort();
|
|
258
|
+
if (files.length === 0) {
|
|
259
|
+
if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// React DOM is only needed when a project actually has emails; load it
|
|
264
|
+
// lazily so a server-only project without emails never pays for it.
|
|
265
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
266
|
+
|
|
267
|
+
const server = await createServer({
|
|
268
|
+
...(await createViteConfig(cfg)),
|
|
269
|
+
server: { middlewareMode: true, hmr: false },
|
|
270
|
+
appType: 'custom',
|
|
271
|
+
logLevel: 'silent',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const rendered: RenderedEmail[] = [];
|
|
275
|
+
try {
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
|
|
278
|
+
let mod: EmailModule;
|
|
279
|
+
try {
|
|
280
|
+
mod = (await server.ssrLoadModule(path.join(emailsDir, file))) as EmailModule;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const r = await renderModule(name, mod, renderToStaticMarkup as (el: unknown) => string);
|
|
286
|
+
if (r) rendered.push(r);
|
|
287
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
await server.close();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (rendered.length === 0) return;
|
|
294
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
295
|
+
fs.writeFileSync(generatedPath, renderModuleSource(rendered));
|
|
296
|
+
process.stdout.write(
|
|
297
|
+
` ✓ emails: generated ${String(rendered.length)} template${rendered.length === 1 ? '' : 's'} (${rendered
|
|
298
|
+
.map((r) => r.name)
|
|
299
|
+
.join(', ')})\n`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Exported for unit testing the pure render/codegen without a Vite server.
|
|
304
|
+
export const __test = { renderModule, renderModuleSource, tokenProps, htmlToText, toPascal };
|
package/src/compiler/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { build as viteBuild, createServer, mergeConfig, type ViteDevServer } fro
|
|
|
11
11
|
// lazily; `create`/`build`/`doctor` must never touch the native binary.
|
|
12
12
|
import type { RunningBackend } from 'toiljs/backend';
|
|
13
13
|
|
|
14
|
-
import { loadConfig } from './config.js';
|
|
14
|
+
import { loadConfig, type ResolvedToilConfig } from './config.js';
|
|
15
|
+
import { renderEmails } from './emails.js';
|
|
15
16
|
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
16
17
|
import { prerenderStaticParams } from './ssg.js';
|
|
17
18
|
import { extractTemplates } from './template-build.js';
|
|
@@ -165,9 +166,11 @@ async function buildServer(root: string): Promise<void> {
|
|
|
165
166
|
* delivering events on Linux after editors replace files via rename, which left hot reload
|
|
166
167
|
* working exactly once. A no-op for client-only projects.
|
|
167
168
|
*/
|
|
168
|
-
function watchServer(
|
|
169
|
+
function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher']): void {
|
|
170
|
+
const root = cfg.root;
|
|
169
171
|
const dirs = serverDirs(root);
|
|
170
172
|
if (dirs.length === 0) return;
|
|
173
|
+
const emailsDir = path.join(root, 'emails');
|
|
171
174
|
|
|
172
175
|
let building = false;
|
|
173
176
|
let queued = false;
|
|
@@ -178,7 +181,10 @@ function watchServer(root: string, watcher: ViteDevServer['watcher']): void {
|
|
|
178
181
|
}
|
|
179
182
|
building = true;
|
|
180
183
|
process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
|
|
181
|
-
|
|
184
|
+
// Recompile emails/*.tsx -> the generated module before the server build,
|
|
185
|
+
// so editing an email template hot-reloads like any other server change.
|
|
186
|
+
renderEmails(cfg)
|
|
187
|
+
.then(() => buildServer(root))
|
|
182
188
|
.then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
|
|
183
189
|
.catch((e: unknown) =>
|
|
184
190
|
process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'),
|
|
@@ -197,9 +203,11 @@ function watchServer(root: string, watcher: ViteDevServer['watcher']): void {
|
|
|
197
203
|
file.endsWith('.ts') &&
|
|
198
204
|
!file.endsWith('.d.ts') &&
|
|
199
205
|
dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
|
|
200
|
-
|
|
206
|
+
const isEmailSource = (file: string): boolean =>
|
|
207
|
+
/\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
|
|
208
|
+
watcher.add([...dirs, emailsDir]);
|
|
201
209
|
watcher.on('all', (_event, file) => {
|
|
202
|
-
if (!isServerSource(file)) return;
|
|
210
|
+
if (!isServerSource(file) && !isEmailSource(file)) return;
|
|
203
211
|
if (timer) clearTimeout(timer);
|
|
204
212
|
timer = setTimeout(rebuild, 150); // debounce bursts (save-all, formatters)
|
|
205
213
|
});
|
|
@@ -260,6 +268,8 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
260
268
|
// Server first: build it (regenerating shared/server.ts) before the client dev server starts.
|
|
261
269
|
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
262
270
|
if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
271
|
+
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
272
|
+
await renderEmails(cfg);
|
|
263
273
|
await buildServer(cfg.root);
|
|
264
274
|
if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
|
|
265
275
|
generate(cfg);
|
|
@@ -302,7 +312,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
302
312
|
|
|
303
313
|
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
304
314
|
// and the dev server hot-swaps the recompiled wasm module.
|
|
305
|
-
watchServer(cfg
|
|
315
|
+
watchServer(cfg, server.watcher);
|
|
306
316
|
return server;
|
|
307
317
|
}
|
|
308
318
|
|
|
@@ -316,6 +326,8 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
316
326
|
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
317
327
|
if (hasServer && !opts.serverOnly)
|
|
318
328
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
329
|
+
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
330
|
+
await renderEmails(cfg);
|
|
319
331
|
await buildServer(cfg.root);
|
|
320
332
|
if (opts.serverOnly) return;
|
|
321
333
|
if (hasServer)
|
|
@@ -41,6 +41,9 @@ export interface EnvelopeRequest {
|
|
|
41
41
|
/** Full request header list, passed through to the guest. */
|
|
42
42
|
readonly headers: readonly (readonly [string, string])[];
|
|
43
43
|
readonly body: Uint8Array;
|
|
44
|
+
/** The connecting client's IP for the `client_ip` import / `ctx.clientIp()`.
|
|
45
|
+
* The edge uses the unspoofable socket peer; the dev server best-efforts it. */
|
|
46
|
+
readonly clientIp?: string;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** The decoded guest response envelope. */
|
package/src/devserver/host.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
|
|
21
|
+
import { ratelimitCheck } from './ratelimit.js';
|
|
21
22
|
|
|
22
23
|
/** Limits identical to the edge's `set_header` / `respond_file` bounds. */
|
|
23
24
|
const MAX_TOTAL_HEADERS_BYTES = 64 * 1024;
|
|
@@ -49,13 +50,23 @@ export interface DispatchState {
|
|
|
49
50
|
headerBytes: number;
|
|
50
51
|
/** File path from `respond_file`, or `null`; when set, the envelope body is ignored. */
|
|
51
52
|
sendfile: string | null;
|
|
53
|
+
/** The connecting client's IP for `client_ip` (the edge uses the socket peer);
|
|
54
|
+
* set per dispatch from the Node request's `socket.remoteAddress`, '' if unknown. */
|
|
55
|
+
clientIp: string;
|
|
52
56
|
/** Per-dispatch Web Crypto keystore + result scratch (mirrors the edge). */
|
|
53
57
|
crypto: CryptoState;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/** A fresh, zeroed per-dispatch state (the edge resets the same way before each request). */
|
|
57
61
|
export function freshDispatchState(): DispatchState {
|
|
58
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
status: null,
|
|
64
|
+
headers: [],
|
|
65
|
+
headerBytes: 0,
|
|
66
|
+
sendfile: null,
|
|
67
|
+
clientIp: '',
|
|
68
|
+
crypto: freshCryptoState(),
|
|
69
|
+
};
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/**
|
|
@@ -135,6 +146,60 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
|
|
|
135
146
|
state.sendfile = readBytes(ref, pathPtr, pathLen).toString('utf8');
|
|
136
147
|
},
|
|
137
148
|
|
|
149
|
+
// Write the client's IP (set per dispatch from the connection's
|
|
150
|
+
// remote address) into the guest buffer. Returns the byte length,
|
|
151
|
+
// 0 if unknown, -1 if the buffer is too small. Mirrors the edge's
|
|
152
|
+
// `client_ip_import.rs`.
|
|
153
|
+
client_ip: (outPtr: number, cap: number): number => {
|
|
154
|
+
const ip = state.clientIp;
|
|
155
|
+
if (ip.length === 0) return 0;
|
|
156
|
+
const bytes = Buffer.from(ip, 'utf8');
|
|
157
|
+
if (bytes.length > cap) return -1;
|
|
158
|
+
const m = mem(ref);
|
|
159
|
+
if (outPtr < 0 || outPtr + bytes.length > m.length)
|
|
160
|
+
throw new Error('client_ip write out of bounds');
|
|
161
|
+
bytes.copy(m, outPtr);
|
|
162
|
+
return bytes.length;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// `@ratelimit` decorator hook. Accounts one event for this request
|
|
166
|
+
// against the dev limiter, keyed on the explicit guest key when
|
|
167
|
+
// given (`keyLen > 0`), else the client IP. Returns the remaining
|
|
168
|
+
// budget (>= 0, allowed) or a negative `Retry-After` in seconds
|
|
169
|
+
// (denied). Mirrors the edge's `ratelimit_check_import.rs`.
|
|
170
|
+
ratelimit_check: (
|
|
171
|
+
routeId: number,
|
|
172
|
+
strategy: number,
|
|
173
|
+
limit: number,
|
|
174
|
+
window: number,
|
|
175
|
+
keyPtr: number,
|
|
176
|
+
keyLen: number,
|
|
177
|
+
): number => {
|
|
178
|
+
const identity =
|
|
179
|
+
keyLen > 0
|
|
180
|
+
? readBytes(ref, keyPtr, keyLen).toString('utf8')
|
|
181
|
+
: state.clientIp || '0';
|
|
182
|
+
const d = ratelimitCheck(routeId, strategy, limit, window, identity, Date.now());
|
|
183
|
+
return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
// `env::email_send`: the dev server has no email provider, so it
|
|
187
|
+
// parses the recipient for a log line and reports Sent (0), the same
|
|
188
|
+
// i32 contract the edge returns. The suspension is a host-side concern
|
|
189
|
+
// on the edge (call_async); the wasm just sees an i32 either way.
|
|
190
|
+
email_send: (reqPtr: number, reqLen: number): number => {
|
|
191
|
+
// Header: u16 to_len | u16 subj_len | u16 purpose_len | u32 body_len
|
|
192
|
+
// | u32 html_len (14 bytes), then payloads; `to` is first.
|
|
193
|
+
const raw = readBytes(ref, reqPtr, reqLen);
|
|
194
|
+
let to = '<unparsed>';
|
|
195
|
+
if (raw.length >= 14) {
|
|
196
|
+
const toLen = raw.readUInt16LE(0);
|
|
197
|
+
if (14 + toLen <= raw.length) to = raw.toString('utf8', 14, 14 + toLen);
|
|
198
|
+
}
|
|
199
|
+
process.stdout.write(` ✉ dev email_send -> ${to} (not actually sent)\n`);
|
|
200
|
+
return 0; // EmailStatus.Sent
|
|
201
|
+
},
|
|
202
|
+
|
|
138
203
|
thread_spawn: (_startArg: number): number => -1,
|
|
139
204
|
|
|
140
205
|
// `Date.now()` -> wall-clock milliseconds, matching the edge host.
|
package/src/devserver/index.ts
CHANGED
|
@@ -108,12 +108,19 @@ function resolveSendfile(root: string, file: string): string | null {
|
|
|
108
108
|
async function toEnvelopeRequest(request: Request): Promise<EnvelopeRequest> {
|
|
109
109
|
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
110
110
|
const body = hasBody ? new Uint8Array(await request.buffer()) : new Uint8Array(0);
|
|
111
|
+
// Dev parity for `client_ip`: the edge keys on the unspoofable socket peer,
|
|
112
|
+
// but the dev server has no DPDK socket, so best-effort from a proxy's
|
|
113
|
+
// `x-forwarded-for`, else localhost, so `ctx.clientIp()` returns a value.
|
|
114
|
+
const xff = request.headers['x-forwarded-for'];
|
|
115
|
+
const clientIp =
|
|
116
|
+
typeof xff === 'string' && xff.length > 0 ? xff.split(',')[0]!.trim() : '127.0.0.1';
|
|
111
117
|
return {
|
|
112
118
|
method: request.method,
|
|
113
119
|
// `url` keeps the query string; the guest's RouteContext parses it off the path.
|
|
114
120
|
path: request.url,
|
|
115
121
|
headers: Object.entries(request.headers),
|
|
116
122
|
body,
|
|
123
|
+
clientIp,
|
|
117
124
|
};
|
|
118
125
|
}
|
|
119
126
|
|
package/src/devserver/module.ts
CHANGED
|
@@ -53,6 +53,7 @@ interface HandleExports {
|
|
|
53
53
|
/** Host functions the dev server provides under `env` (see `host.ts`). */
|
|
54
54
|
const PROVIDED_IMPORTS = new Set([
|
|
55
55
|
'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn', 'Date.now',
|
|
56
|
+
'client_ip', 'ratelimit_check', 'email_send',
|
|
56
57
|
// Web Crypto host functions (see ./crypto.ts).
|
|
57
58
|
'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
|
|
58
59
|
'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
|
|
@@ -109,6 +110,7 @@ export class WasmServerModule {
|
|
|
109
110
|
|
|
110
111
|
const ref: MemoryRef = { memory: null };
|
|
111
112
|
const state = freshDispatchState();
|
|
113
|
+
state.clientIp = req.clientIp ?? '';
|
|
112
114
|
const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
|
|
113
115
|
const exports = instance.exports as unknown as HandleExports;
|
|
114
116
|
ref.memory = exports.memory;
|
|
@@ -165,6 +167,7 @@ export class WasmServerModule {
|
|
|
165
167
|
const envelope = encodeRequestEnvelope(req);
|
|
166
168
|
const ref: MemoryRef = { memory: null };
|
|
167
169
|
const state = freshDispatchState();
|
|
170
|
+
state.clientIp = req.clientIp ?? '';
|
|
168
171
|
const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
|
|
169
172
|
const exports = instance.exports as unknown as HandleExports & {
|
|
170
173
|
render?: (reqOfs: number, reqLen: number) => bigint;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-server rate limiter: a single-process mirror of the edge's
|
|
3
|
+
* `toil-backend/src/ratelimit.rs` strategies, so a tenant using the
|
|
4
|
+
* `@ratelimit(...)` decorator behaves the same under `toiljs dev` as on the
|
|
5
|
+
* edge. The edge runs an EXACT limiter shared across 14 workers; dev is one
|
|
6
|
+
* process, so a plain module-level registry is already "global".
|
|
7
|
+
*
|
|
8
|
+
* State lives here (module scope), NOT in the per-request DispatchState, because
|
|
9
|
+
* a limiter must persist across requests (the dev server builds a fresh wasm
|
|
10
|
+
* instance per request, exactly like the edge pools).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Mirror of the host's strategy tags (`Strategy` in `ratelimit.rs`). */
|
|
14
|
+
export const STRATEGY_FIXED = 0;
|
|
15
|
+
export const STRATEGY_SLIDING = 1;
|
|
16
|
+
export const STRATEGY_TOKEN_BUCKET = 2;
|
|
17
|
+
|
|
18
|
+
interface KeyState {
|
|
19
|
+
/** Token bucket: tokens * 1000 (sub-token refill without floats). */
|
|
20
|
+
tokensMilli: number;
|
|
21
|
+
/** Window strategies: aligned index of the current bucket. */
|
|
22
|
+
window: number;
|
|
23
|
+
cur: number;
|
|
24
|
+
prev: number;
|
|
25
|
+
lastMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RouteLimiter {
|
|
29
|
+
strategy: number;
|
|
30
|
+
/** `limit`/`window_secs` for windows; `burst`/`refill_per_sec` for the bucket. */
|
|
31
|
+
a: number;
|
|
32
|
+
b: number;
|
|
33
|
+
keys: Map<string, KeyState>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `(routeId) -> limiter`, created lazily with the route's first-seen params. */
|
|
37
|
+
const registry = new Map<number, RouteLimiter>();
|
|
38
|
+
|
|
39
|
+
export interface DevDecision {
|
|
40
|
+
allowed: boolean;
|
|
41
|
+
/** Whole seconds to wait before retrying (>= 1 when denied, 0 when allowed). */
|
|
42
|
+
retryAfterSecs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Account one event for `identity` against route `routeId` and return the
|
|
47
|
+
* verdict. Mirrors `SharedLimiter::check` semantics. `now` is wall-clock ms
|
|
48
|
+
* (`Date.now()`), which is fine for a single dev process.
|
|
49
|
+
*/
|
|
50
|
+
export function ratelimitCheck(
|
|
51
|
+
routeId: number,
|
|
52
|
+
strategy: number,
|
|
53
|
+
limit: number,
|
|
54
|
+
window: number,
|
|
55
|
+
identity: string,
|
|
56
|
+
now: number,
|
|
57
|
+
): DevDecision {
|
|
58
|
+
let rl = registry.get(routeId);
|
|
59
|
+
if (rl === undefined) {
|
|
60
|
+
rl = { strategy, a: Math.max(1, limit), b: Math.max(1, window), keys: new Map() };
|
|
61
|
+
registry.set(routeId, rl);
|
|
62
|
+
}
|
|
63
|
+
if (rl.strategy === STRATEGY_TOKEN_BUCKET) return checkBucket(rl, identity, now);
|
|
64
|
+
return checkWindow(rl, identity, now, rl.strategy === STRATEGY_SLIDING);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkBucket(rl: RouteLimiter, key: string, now: number): DevDecision {
|
|
68
|
+
const capMilli = rl.a * 1000;
|
|
69
|
+
const refillPerSec = rl.b;
|
|
70
|
+
let st = rl.keys.get(key);
|
|
71
|
+
if (st === undefined) {
|
|
72
|
+
st = { tokensMilli: capMilli, window: 0, cur: 0, prev: 0, lastMs: now };
|
|
73
|
+
rl.keys.set(key, st);
|
|
74
|
+
}
|
|
75
|
+
const elapsed = Math.max(0, now - st.lastMs);
|
|
76
|
+
st.tokensMilli = Math.min(capMilli, st.tokensMilli + elapsed * refillPerSec);
|
|
77
|
+
st.lastMs = now;
|
|
78
|
+
if (st.tokensMilli >= 1000) {
|
|
79
|
+
st.tokensMilli -= 1000;
|
|
80
|
+
return { allowed: true, retryAfterSecs: 0 };
|
|
81
|
+
}
|
|
82
|
+
const needed = 1000 - st.tokensMilli;
|
|
83
|
+
const waitMs = Math.ceil(needed / refillPerSec);
|
|
84
|
+
return { allowed: false, retryAfterSecs: Math.max(1, Math.ceil(waitMs / 1000)) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkWindow(rl: RouteLimiter, key: string, now: number, sliding: boolean): DevDecision {
|
|
88
|
+
const limit = rl.a;
|
|
89
|
+
const windowMs = rl.b * 1000;
|
|
90
|
+
const curWindow = Math.floor(now / windowMs);
|
|
91
|
+
let st = rl.keys.get(key);
|
|
92
|
+
if (st === undefined) {
|
|
93
|
+
st = { tokensMilli: 0, window: curWindow, cur: 0, prev: 0, lastMs: now };
|
|
94
|
+
rl.keys.set(key, st);
|
|
95
|
+
}
|
|
96
|
+
if (curWindow === st.window + 1) {
|
|
97
|
+
st.prev = st.cur;
|
|
98
|
+
st.cur = 0;
|
|
99
|
+
st.window = curWindow;
|
|
100
|
+
} else if (curWindow > st.window) {
|
|
101
|
+
st.prev = 0;
|
|
102
|
+
st.cur = 0;
|
|
103
|
+
st.window = curWindow;
|
|
104
|
+
}
|
|
105
|
+
st.lastMs = now;
|
|
106
|
+
const posInWindow = now % windowMs;
|
|
107
|
+
const effective = sliding
|
|
108
|
+
? Math.floor((st.prev * (windowMs - posInWindow)) / windowMs) + st.cur
|
|
109
|
+
: st.cur;
|
|
110
|
+
if (effective < limit) {
|
|
111
|
+
st.cur += 1;
|
|
112
|
+
return { allowed: true, retryAfterSecs: 0 };
|
|
113
|
+
}
|
|
114
|
+
const waitMs = windowMs - posInWindow;
|
|
115
|
+
return { allowed: false, retryAfterSecs: Math.max(1, Math.ceil(waitMs / 1000)) };
|
|
116
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// integration). Imports the specific modules rather than the runtime index so
|
|
3
3
|
// `securecookies.ts` is not pulled into the as-pect graph: it depends on the
|
|
4
4
|
// toilscript crypto std (`crypto` / `data` / `bindings/webcrypto`), which the
|
|
5
|
-
// as-pect compiler
|
|
5
|
+
// as-pect compiler does not ship. `SecureCookies`
|
|
6
6
|
// is exercised end-to-end against the real toilscript-compiled wasm in
|
|
7
7
|
// `test/devserver.test.ts`.
|
|
8
8
|
import { Method, Request, Header } from '../../server/runtime/request';
|