toiljs 0.0.39 → 0.0.41

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.
@@ -0,0 +1,311 @@
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(/&nbsp;/g, ' ')
114
+ .replace(/&#x27;|&#39;/g, "'")
115
+ .replace(/&quot;|&#34;/g, '"')
116
+ .replace(/&lt;/g, '<')
117
+ .replace(/&gt;/g, '>')
118
+ .replace(/&amp;/g, '&') // last: so "&amp;lt;" -> "&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
+ // Only (re)write when the output actually changed: an unconditional write
295
+ // bumps the file's mtime every rebuild, which under `dev` would re-trigger
296
+ // the watcher. (The watcher also ignores this file by name; this is belt-and-
297
+ // suspenders and avoids needless work.)
298
+ const next = renderModuleSource(rendered);
299
+ const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
300
+ if (prev === next) return;
301
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
302
+ fs.writeFileSync(generatedPath, next);
303
+ process.stdout.write(
304
+ ` ✓ emails: generated ${String(rendered.length)} template${rendered.length === 1 ? '' : 's'} (${rendered
305
+ .map((r) => r.name)
306
+ .join(', ')})\n`,
307
+ );
308
+ }
309
+
310
+ // Exported for unit testing the pure render/codegen without a Vite server.
311
+ export const __test = { renderModule, renderModuleSource, tokenProps, htmlToText, toPascal };
@@ -107,7 +107,18 @@ export const TOIL_SERVER_ENV_DTS =
107
107
  `declare const Cookies: typeof import('toiljs/server/runtime/http/cookies').Cookies;\n` +
108
108
  `type Cookies = import('toiljs/server/runtime/http/cookies').Cookies;\n` +
109
109
  `declare const SecureCookies: typeof import('toiljs/server/runtime/http/securecookies').SecureCookies;\n` +
110
- `type SecureCookies = import('toiljs/server/runtime/http/securecookies').SecureCookies;\n`;
110
+ `type SecureCookies = import('toiljs/server/runtime/http/securecookies').SecureCookies;\n` +
111
+ `// Email, rate-limit, 2FA, and auth globals (server/globals/*), hand-declared\n` +
112
+ `// because their AssemblyScript source can't be type-aliased from tsc.\n` +
113
+ `declare enum EmailStatus { Sent, Disabled, Budget, RecipientCapped, Deduped, TryLater, BadRecipient, ProviderError }\n` +
114
+ `declare namespace EmailService { function send(to: string, subject: string, body: string, purpose?: string, html?: string): EmailStatus; }\n` +
115
+ `declare class RenderedEmail { subject: string; body: string; html: string; constructor(subject: string, body: string, html: string); }\n` +
116
+ `declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }\n` +
117
+ `declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }\n` +
118
+ `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
119
+ `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
120
+ `declare namespace TwoFactor { function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
121
+ `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n`;
111
122
 
112
123
  /**
113
124
  * Returns a `./`-prefixed, **extensionless** POSIX module specifier from `.toil` to `abs`, for use
@@ -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(root: string, watcher: ViteDevServer['watcher']): void {
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
- buildServer(root)
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'),
@@ -196,10 +202,15 @@ function watchServer(root: string, watcher: ViteDevServer['watcher']): void {
196
202
  const isServerSource = (file: string): boolean =>
197
203
  file.endsWith('.ts') &&
198
204
  !file.endsWith('.d.ts') &&
205
+ // `_emails.ts` is GENERATED by renderEmails on every rebuild; reacting to
206
+ // our own output would loop forever (rebuild -> write -> rebuild -> ...).
207
+ path.basename(file) !== '_emails.ts' &&
199
208
  dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
200
- watcher.add(dirs);
209
+ const isEmailSource = (file: string): boolean =>
210
+ /\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
211
+ watcher.add([...dirs, emailsDir]);
201
212
  watcher.on('all', (_event, file) => {
202
- if (!isServerSource(file)) return;
213
+ if (!isServerSource(file) && !isEmailSource(file)) return;
203
214
  if (timer) clearTimeout(timer);
204
215
  timer = setTimeout(rebuild, 150); // debounce bursts (save-all, formatters)
205
216
  });
@@ -260,6 +271,8 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
260
271
  // Server first: build it (regenerating shared/server.ts) before the client dev server starts.
261
272
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
262
273
  if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
274
+ // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
275
+ await renderEmails(cfg);
263
276
  await buildServer(cfg.root);
264
277
  if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
265
278
  generate(cfg);
@@ -302,7 +315,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
302
315
 
303
316
  // Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
304
317
  // and the dev server hot-swaps the recompiled wasm module.
305
- watchServer(cfg.root, server.watcher);
318
+ watchServer(cfg, server.watcher);
306
319
  return server;
307
320
  }
308
321
 
@@ -316,6 +329,8 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
316
329
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
317
330
  if (hasServer && !opts.serverOnly)
318
331
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
332
+ // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
333
+ await renderEmails(cfg);
319
334
  await buildServer(cfg.root);
320
335
  if (opts.serverOnly) return;
321
336
  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. */
@@ -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 { status: null, headers: [], headerBytes: 0, sendfile: null, crypto: freshCryptoState() };
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.
@@ -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
 
@@ -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
+ }