toiljs 0.0.115 → 0.0.117

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.
Files changed (326) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +16 -7
  3. package/TYPESCRIPT_LAW.md +1946 -2073
  4. package/build/backend/.tsbuildinfo +1 -1
  5. package/build/cli/.tsbuildinfo +1 -1
  6. package/build/cli/index.js +2375 -258
  7. package/build/client/.tsbuildinfo +1 -1
  8. package/build/client/dev/devtools.js +1 -1
  9. package/build/client/dev/error-overlay.js +1 -1
  10. package/build/client/routing/mount.js +1 -1
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/auth-emails.js +7 -3
  13. package/build/compiler/emails.js +1 -1
  14. package/build/compiler/index.d.ts +1 -0
  15. package/build/compiler/index.js +25 -4
  16. package/build/compiler/pages.js +2 -3
  17. package/build/compiler/prerender.d.ts +3 -8
  18. package/build/compiler/prerender.js +88 -127
  19. package/build/compiler/ssg.js +3 -4
  20. package/build/compiler/template-build.js +3 -4
  21. package/build/compiler/toil-docs.generated.js +6 -5
  22. package/build/compiler/vite.js +3 -6
  23. package/build/devserver/.tsbuildinfo +1 -1
  24. package/build/devserver/db/database.js +7 -3
  25. package/build/devserver/db/types.js +1 -1
  26. package/build/devserver/production.js +4 -3
  27. package/build/devserver/runtime/crypto.d.ts +1 -1
  28. package/build/io/.tsbuildinfo +1 -1
  29. package/build/logger/.tsbuildinfo +1 -1
  30. package/build/shared/.tsbuildinfo +1 -1
  31. package/examples/basic/client/routes/analytics.tsx +7 -5
  32. package/examples/basic/server/tsconfig.json +1 -3
  33. package/package.json +45 -44
  34. package/presets/no-uint8array-tostring.js +120 -153
  35. package/presets/oxlint.d.ts +5 -0
  36. package/presets/oxlint.js +224 -0
  37. package/server/runtime/tsconfig.json +1 -3
  38. package/tsconfig.json +2 -11
  39. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
  40. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
  41. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  42. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  43. package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
  44. package/.github/changelog-config.json +0 -45
  45. package/.github/dependabot.yml +0 -27
  46. package/.github/workflows/ci.yml +0 -222
  47. package/.vscode/settings.json +0 -10
  48. package/docs/README.md +0 -107
  49. package/docs/auth/README.md +0 -104
  50. package/docs/auth/configuration.md +0 -105
  51. package/docs/auth/emails.md +0 -92
  52. package/docs/auth/extending.md +0 -202
  53. package/docs/auth/how-it-works.md +0 -138
  54. package/docs/auth/usage.md +0 -188
  55. package/docs/backend/README.md +0 -143
  56. package/docs/backend/data.md +0 -351
  57. package/docs/backend/rest.md +0 -402
  58. package/docs/backend/rpc.md +0 -226
  59. package/docs/background/README.md +0 -114
  60. package/docs/background/daemons.md +0 -276
  61. package/docs/background/derive.md +0 -179
  62. package/docs/cli/README.md +0 -379
  63. package/docs/concepts/ai-guide.md +0 -132
  64. package/docs/concepts/config.md +0 -416
  65. package/docs/concepts/decorators.md +0 -127
  66. package/docs/concepts/security.md +0 -108
  67. package/docs/concepts/tiers.md +0 -166
  68. package/docs/concepts/types.md +0 -216
  69. package/docs/database/README.md +0 -143
  70. package/docs/database/capacity.md +0 -350
  71. package/docs/database/counters.md +0 -174
  72. package/docs/database/documents.md +0 -277
  73. package/docs/database/events.md +0 -307
  74. package/docs/database/membership.md +0 -246
  75. package/docs/database/setup.md +0 -155
  76. package/docs/database/unique.md +0 -216
  77. package/docs/database/views.md +0 -246
  78. package/docs/frontend/README.md +0 -104
  79. package/docs/frontend/components.md +0 -286
  80. package/docs/frontend/data-fetching.md +0 -260
  81. package/docs/frontend/images.md +0 -148
  82. package/docs/frontend/metadata.md +0 -344
  83. package/docs/frontend/navigation.md +0 -296
  84. package/docs/frontend/rendering.md +0 -236
  85. package/docs/frontend/routing.md +0 -346
  86. package/docs/frontend/scripts.md +0 -118
  87. package/docs/frontend/search.md +0 -191
  88. package/docs/frontend/styling.md +0 -147
  89. package/docs/frontend/toil-global.md +0 -216
  90. package/docs/getting-started/README.md +0 -81
  91. package/docs/getting-started/create-project.md +0 -131
  92. package/docs/getting-started/deploy.md +0 -101
  93. package/docs/getting-started/first-app.md +0 -215
  94. package/docs/getting-started/installation.md +0 -124
  95. package/docs/getting-started/migrating.md +0 -125
  96. package/docs/getting-started/project-structure.md +0 -163
  97. package/docs/introduction/README.md +0 -66
  98. package/docs/introduction/design-principles.md +0 -81
  99. package/docs/introduction/distributed.md +0 -91
  100. package/docs/introduction/how-it-works.md +0 -121
  101. package/docs/introduction/hyperscale.md +0 -70
  102. package/docs/introduction/modern-stack.md +0 -85
  103. package/docs/introduction/vs-other-frameworks.md +0 -64
  104. package/docs/introduction/why-toil.md +0 -99
  105. package/docs/llms.txt +0 -95
  106. package/docs/realtime/README.md +0 -102
  107. package/docs/realtime/channels.md +0 -211
  108. package/docs/realtime/streams.md +0 -369
  109. package/docs/services/README.md +0 -60
  110. package/docs/services/analytics.md +0 -279
  111. package/docs/services/caching.md +0 -175
  112. package/docs/services/cookies.md +0 -235
  113. package/docs/services/crypto.md +0 -209
  114. package/docs/services/email.md +0 -289
  115. package/docs/services/environment.md +0 -141
  116. package/docs/services/ratelimit.md +0 -174
  117. package/docs/services/time.md +0 -85
  118. package/eslint.config.js +0 -57
  119. package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
  120. package/presets/eslint.js +0 -88
  121. package/src/backend/index.ts +0 -204
  122. package/src/cli/configure.ts +0 -363
  123. package/src/cli/create.ts +0 -924
  124. package/src/cli/db.ts +0 -158
  125. package/src/cli/diagnostics.ts +0 -665
  126. package/src/cli/doctor.ts +0 -982
  127. package/src/cli/features.ts +0 -170
  128. package/src/cli/index.ts +0 -326
  129. package/src/cli/notify.ts +0 -120
  130. package/src/cli/proc.ts +0 -56
  131. package/src/cli/ui.ts +0 -169
  132. package/src/cli/update.ts +0 -248
  133. package/src/cli/updates.ts +0 -92
  134. package/src/cli/validate.ts +0 -31
  135. package/src/cli/version-check.ts +0 -125
  136. package/src/client/auth.ts +0 -869
  137. package/src/client/channel/channel.ts +0 -146
  138. package/src/client/components/Form.tsx +0 -65
  139. package/src/client/components/Image.tsx +0 -160
  140. package/src/client/components/Script.tsx +0 -113
  141. package/src/client/components/Slot.tsx +0 -21
  142. package/src/client/dev/devtools.tsx +0 -1089
  143. package/src/client/dev/error-overlay.tsx +0 -225
  144. package/src/client/errors.ts +0 -11
  145. package/src/client/head/head.ts +0 -182
  146. package/src/client/head/metadata.ts +0 -110
  147. package/src/client/index.ts +0 -130
  148. package/src/client/navigation/Link.tsx +0 -108
  149. package/src/client/navigation/NavLink.tsx +0 -86
  150. package/src/client/navigation/navigation.ts +0 -235
  151. package/src/client/navigation/prefetch.ts +0 -169
  152. package/src/client/navigation/scroll.ts +0 -53
  153. package/src/client/routing/Router.tsx +0 -216
  154. package/src/client/routing/action.ts +0 -122
  155. package/src/client/routing/error-boundary.tsx +0 -43
  156. package/src/client/routing/hooks.ts +0 -140
  157. package/src/client/routing/lazy.ts +0 -77
  158. package/src/client/routing/loader.ts +0 -381
  159. package/src/client/routing/match.ts +0 -47
  160. package/src/client/routing/mount.tsx +0 -90
  161. package/src/client/routing/params-context.ts +0 -10
  162. package/src/client/routing/slot-context.ts +0 -7
  163. package/src/client/rpc.ts +0 -97
  164. package/src/client/search/search.ts +0 -189
  165. package/src/client/search/use-page-search.ts +0 -73
  166. package/src/client/ssr/markers.tsx +0 -172
  167. package/src/client/stream/client.ts +0 -237
  168. package/src/client/types.ts +0 -73
  169. package/src/compiler/auth-emails.ts +0 -312
  170. package/src/compiler/config.ts +0 -369
  171. package/src/compiler/docs.ts +0 -127
  172. package/src/compiler/email-preview.ts +0 -314
  173. package/src/compiler/emails.ts +0 -404
  174. package/src/compiler/fonts.ts +0 -89
  175. package/src/compiler/generate.ts +0 -471
  176. package/src/compiler/image-blur.ts +0 -42
  177. package/src/compiler/image-report.ts +0 -88
  178. package/src/compiler/index.ts +0 -1362
  179. package/src/compiler/pages.ts +0 -49
  180. package/src/compiler/plugin.ts +0 -391
  181. package/src/compiler/prerender.ts +0 -263
  182. package/src/compiler/routes.ts +0 -170
  183. package/src/compiler/seo.ts +0 -415
  184. package/src/compiler/sri.ts +0 -144
  185. package/src/compiler/ssg.ts +0 -177
  186. package/src/compiler/ssr-codegen.ts +0 -85
  187. package/src/compiler/template-build.ts +0 -676
  188. package/src/compiler/template.ts +0 -262
  189. package/src/compiler/toil-docs.generated.ts +0 -77
  190. package/src/compiler/vite.ts +0 -245
  191. package/src/devserver/analytics/index.ts +0 -317
  192. package/src/devserver/config/dotenv.ts +0 -102
  193. package/src/devserver/config/env.ts +0 -30
  194. package/src/devserver/config/ratelimit.ts +0 -123
  195. package/src/devserver/daemon/catalog.ts +0 -120
  196. package/src/devserver/daemon/cron.ts +0 -87
  197. package/src/devserver/daemon/host.ts +0 -134
  198. package/src/devserver/daemon/index.ts +0 -369
  199. package/src/devserver/daemon/runtime.ts +0 -48
  200. package/src/devserver/db/catalog.ts +0 -109
  201. package/src/devserver/db/database.ts +0 -1834
  202. package/src/devserver/db/derives.ts +0 -121
  203. package/src/devserver/db/index.ts +0 -20
  204. package/src/devserver/db/routeKinds.ts +0 -191
  205. package/src/devserver/db/types.ts +0 -168
  206. package/src/devserver/email/caps.ts +0 -0
  207. package/src/devserver/email/config.ts +0 -141
  208. package/src/devserver/email/console.ts +0 -348
  209. package/src/devserver/email/index.ts +0 -155
  210. package/src/devserver/email/providers.ts +0 -131
  211. package/src/devserver/email/status.ts +0 -23
  212. package/src/devserver/email/validate.ts +0 -37
  213. package/src/devserver/email/wire.ts +0 -55
  214. package/src/devserver/http/cache.ts +0 -0
  215. package/src/devserver/http/envelope.ts +0 -157
  216. package/src/devserver/http/proxy.ts +0 -172
  217. package/src/devserver/http/runtime.ts +0 -387
  218. package/src/devserver/index.ts +0 -43
  219. package/src/devserver/production-ipc.ts +0 -63
  220. package/src/devserver/production-worker.ts +0 -83
  221. package/src/devserver/production.ts +0 -718
  222. package/src/devserver/runtime/crypto.ts +0 -587
  223. package/src/devserver/runtime/host.ts +0 -427
  224. package/src/devserver/runtime/module.ts +0 -442
  225. package/src/devserver/server.ts +0 -251
  226. package/src/devserver/ssr.ts +0 -208
  227. package/src/devserver/stream/catalog.ts +0 -106
  228. package/src/devserver/stream/host.ts +0 -42
  229. package/src/devserver/stream/index.ts +0 -301
  230. package/src/devserver/stream/manager.ts +0 -167
  231. package/src/devserver/stream/router.ts +0 -101
  232. package/src/devserver/stream/wire.ts +0 -58
  233. package/src/devserver/stream/ws.ts +0 -78
  234. package/src/devserver/wasm/sections.ts +0 -59
  235. package/src/devserver/wasm/surface.ts +0 -115
  236. package/src/io/FastMap.ts +0 -151
  237. package/src/io/FastSet.ts +0 -113
  238. package/src/io/codec.ts +0 -362
  239. package/src/io/index.ts +0 -10
  240. package/src/io/lengths.ts +0 -14
  241. package/src/io/types.ts +0 -19
  242. package/src/logger/index.ts +0 -22
  243. package/src/shared/index.ts +0 -46
  244. package/test/analytics-dev.test.ts +0 -113
  245. package/test/assembly/aspect-shim.ts +0 -33
  246. package/test/assembly/cookie.spec.ts +0 -303
  247. package/test/assembly/example.spec.ts +0 -22
  248. package/test/assembly/ssr.spec.ts +0 -95
  249. package/test/assembly.test.ts +0 -59
  250. package/test/auth-emails.test.ts +0 -118
  251. package/test/built-ssr.test.ts +0 -148
  252. package/test/channel.test.ts +0 -21
  253. package/test/config-host-port.test.ts +0 -59
  254. package/test/configure.test.ts +0 -94
  255. package/test/daemon-build.test.ts +0 -278
  256. package/test/daemon-catalog.test.ts +0 -349
  257. package/test/daemon-emulation.test.ts +0 -256
  258. package/test/db.test.ts +0 -0
  259. package/test/devserver-database.test.ts +0 -1102
  260. package/test/devserver-email.test.ts +0 -306
  261. package/test/devserver-pqauth.test.ts +0 -93
  262. package/test/devserver-secrets.test.ts +0 -63
  263. package/test/devserver.test.ts +0 -259
  264. package/test/doctor.test.ts +0 -298
  265. package/test/dom/Image.test.tsx +0 -144
  266. package/test/dom/Link.test.tsx +0 -67
  267. package/test/dom/NavLink.test.tsx +0 -37
  268. package/test/dom/Script.test.tsx +0 -48
  269. package/test/dom/action.test.tsx +0 -146
  270. package/test/dom/error-overlay.test.tsx +0 -44
  271. package/test/dom/loader.test.tsx +0 -121
  272. package/test/dom/navigation.test.ts +0 -59
  273. package/test/dom/revalidate.test.tsx +0 -38
  274. package/test/dom/route-head.test.tsx +0 -56
  275. package/test/dom/router-loading.test.tsx +0 -44
  276. package/test/dom/scroll.test.ts +0 -56
  277. package/test/dom/slot.test.tsx +0 -131
  278. package/test/dom/use-metadata.test.tsx +0 -58
  279. package/test/dom/view-transitions.test.tsx +0 -53
  280. package/test/dynamic-routing.test.ts +0 -132
  281. package/test/email-console.test.ts +0 -86
  282. package/test/email-preview.test.ts +0 -73
  283. package/test/emails.test.ts +0 -58
  284. package/test/errors.test.ts +0 -21
  285. package/test/example-guestbook.test.ts +0 -123
  286. package/test/features.test.ts +0 -149
  287. package/test/fixtures/bignum-wire/spec.ts +0 -25
  288. package/test/fixtures/daemon-app.ts +0 -65
  289. package/test/fixtures/stream-echo.ts +0 -26
  290. package/test/fixtures/stream-gate.ts +0 -28
  291. package/test/fixtures/stream-trap.ts +0 -18
  292. package/test/fixtures/stream-typed.ts +0 -41
  293. package/test/fonts.test.ts +0 -28
  294. package/test/global-setup.ts +0 -17
  295. package/test/head.test.ts +0 -42
  296. package/test/image-blur.test.ts +0 -50
  297. package/test/io.test.ts +0 -117
  298. package/test/metadata.test.ts +0 -40
  299. package/test/navlink.test.ts +0 -28
  300. package/test/pages.test.ts +0 -105
  301. package/test/placeholder.test.ts +0 -9
  302. package/test/pqauth-e2e.test.ts +0 -216
  303. package/test/pqauth-email.test.ts +0 -801
  304. package/test/prerender.test.ts +0 -116
  305. package/test/prettier-plugin.test.ts +0 -46
  306. package/test/routes.test.ts +0 -76
  307. package/test/rpc-bignum-wire.test.ts +0 -164
  308. package/test/rpc-dispatch.test.ts +0 -132
  309. package/test/rpc-kinds.test.ts +0 -18
  310. package/test/rpc.test.ts +0 -66
  311. package/test/search.test.ts +0 -114
  312. package/test/seo.test.ts +0 -175
  313. package/test/slot-layouts.test.ts +0 -69
  314. package/test/sri.test.ts +0 -129
  315. package/test/ssg.test.ts +0 -36
  316. package/test/ssr-hydration.test.tsx +0 -122
  317. package/test/ssr-render.test.ts +0 -197
  318. package/test/ssr-template.test.tsx +0 -482
  319. package/test/stream-emulation.test.ts +0 -479
  320. package/test/ui.test.ts +0 -40
  321. package/test/update.test.ts +0 -58
  322. package/test/validate.test.ts +0 -42
  323. package/test/version-check.test.ts +0 -130
  324. package/tests/data-parity/generated-parity.ts +0 -99
  325. package/tests/data-parity/parity.ts +0 -80
  326. package/tests/data-parity/spec.ts +0 -46
@@ -1,348 +0,0 @@
1
- /**
2
- * DEV EMAIL EMULATOR: renders an outgoing email as an ASCII-drawn box in the
3
- * terminal so confirm / reset / 2FA flows are testable locally WITHOUT a real
4
- * inbox (the whole point of dev). It converts the HTML body to readable text WITH
5
- * terminal color + font styling (bold / italic / underline / colored text taken
6
- * from the HTML), lays it out under a left-barred box, and highlights the ACTION
7
- * (the one-time link, which carries its token in the `#token=` fragment and so is
8
- * directly clickable, or the numeric 2FA code). Dev-only; the production edge never
9
- * renders or logs token material.
10
- *
11
- * The box uses a LEFT bar plus full top/bottom rules (no right border), so colored
12
- * spans never break column alignment and long URLs stay on one unbroken, clickable
13
- * line. Styling is emitted only when the terminal supports color (a piped/CI run
14
- * gets clean plain text).
15
- */
16
- import pc from 'picocolors';
17
-
18
- import { type ParsedEmail } from './wire.js';
19
-
20
- const WIDTH = 68; // wrap column for body text (URLs are left un-wrapped)
21
-
22
- /** Decode the handful of HTML entities our built-in templates can emit. */
23
- function decodeEntities(s: string): string {
24
- return s
25
- .replace(/ /g, ' ')
26
- .replace(/&/g, '&')
27
- .replace(/&lt;/g, '<')
28
- .replace(/&gt;/g, '>')
29
- .replace(/&quot;/g, '"')
30
- .replace(/&#0*39;|&apos;/g, "'")
31
- .replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => String.fromCodePoint(parseInt(h, 16)))
32
- .replace(/&#(\d+);/g, (_m, d: string) => String.fromCodePoint(parseInt(d, 10)));
33
- }
34
-
35
- /**
36
- * A plain-text (no color) rendering of an HTML email body: anchors become
37
- * `text (url)`, block elements become line breaks, remaining tags are stripped,
38
- * entities decoded, whitespace collapsed. Kept for the action-extraction fallback
39
- * and any caller that wants text without escape codes.
40
- */
41
- export function htmlToText(html: string): string {
42
- let s = html;
43
- // <a href="URL">TEXT</a> -> "TEXT (URL)" (or just URL when text == url / empty)
44
- s = s.replace(
45
- /<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
46
- (_m, href: string, inner: string) => {
47
- const t = inner.replace(/<[^>]+>/g, '').trim();
48
- return t.length > 0 && t !== href ? `${t} (${href})` : href;
49
- },
50
- );
51
- s = s.replace(/<br\s*\/?>/gi, '\n');
52
- s = s.replace(/<\/?(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|section|header|footer)\b[^>]*>/gi, '\n');
53
- s = s.replace(/<[^>]+>/g, '');
54
- s = decodeEntities(s);
55
- s = s.replace(/[ \t]+/g, ' ');
56
- s = s
57
- .split('\n')
58
- .map((l) => l.trim())
59
- .join('\n')
60
- .replace(/\n{3,}/g, '\n\n')
61
- .trim();
62
- return s;
63
- }
64
-
65
- /** The actionable bit of an email: the first link, else a 4-8 digit code, else null. */
66
- export function emailAction(parsed: ParsedEmail): { kind: 'link' | 'code'; value: string } | null {
67
- const body = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
68
- const link = /https?:\/\/[^\s"'<>)]+/.exec(body);
69
- if (link !== null) return { kind: 'link', value: link[0] };
70
- const code = /\b(\d{4,8})\b/.exec(body);
71
- if (code !== null) return { kind: 'code', value: code[1] };
72
- return null;
73
- }
74
-
75
- // --- HTML -> STYLED terminal spans -----------------------------------------------------------------
76
-
77
- type Rgb = readonly [number, number, number];
78
- interface Style {
79
- bold?: boolean;
80
- italic?: boolean;
81
- underline?: boolean;
82
- color?: Rgb;
83
- }
84
- interface Word {
85
- readonly text: string;
86
- readonly style: Style;
87
- /** Whether whitespace separated this word from the previous one in the source
88
- * (so `<b>bob</b>,` renders as `bob,` not `bob ,`). */
89
- readonly spaceBefore: boolean;
90
- }
91
-
92
- /** A small palette of CSS color names our templates (and most emails) use. */
93
- const NAMED: Record<string, Rgb> = {
94
- black: [0, 0, 0], white: [235, 235, 235], red: [229, 57, 53], green: [67, 160, 71],
95
- blue: [30, 136, 229], yellow: [253, 216, 53], orange: [251, 140, 0], purple: [142, 36, 170],
96
- magenta: [216, 27, 96], pink: [233, 30, 99], cyan: [0, 172, 193], teal: [0, 137, 123],
97
- indigo: [57, 73, 171], gray: [128, 128, 128], grey: [128, 128, 128], silver: [192, 192, 192],
98
- gold: [255, 193, 7], brown: [121, 85, 72], navy: [26, 35, 126], lime: [124, 179, 66],
99
- };
100
-
101
- /** Parse a CSS color value (`#rgb`, `#rrggbb`, `rgb(...)`, or a named color) to RGB. */
102
- export function parseColor(value: string): Rgb | undefined {
103
- const s = value.trim().toLowerCase();
104
- const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
105
- if (hex !== null) {
106
- const h = hex[1];
107
- if (h.length === 3) {
108
- return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];
109
- }
110
- return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
111
- }
112
- const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/.exec(s);
113
- if (rgb !== null) {
114
- return [Math.min(255, +rgb[1]), Math.min(255, +rgb[2]), Math.min(255, +rgb[3])];
115
- }
116
- return NAMED[s];
117
- }
118
-
119
- /** The style delta a tag (name + raw attributes) contributes. */
120
- function styleFromTag(name: string, attrs: string): Style {
121
- const d: Style = {};
122
- if (name === 'b' || name === 'strong' || /^h[1-6]$/.test(name)) d.bold = true;
123
- if (name === 'i' || name === 'em') d.italic = true;
124
- if (name === 'u' || name === 'ins') d.underline = true;
125
- if (name === 'a') {
126
- d.underline = true;
127
- d.color = NAMED.cyan; // links stand out
128
- }
129
- if (name === 'code' || name === 'kbd' || name === 'pre' || name === 'samp') d.color = NAMED.gray;
130
- const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(attrs);
131
- if (styleAttr !== null) {
132
- const css = styleAttr[1].toLowerCase();
133
- const colorM = /(?:^|;)\s*color\s*:\s*([^;]+)/.exec(css);
134
- if (colorM !== null) {
135
- const c = parseColor(colorM[1]);
136
- if (c !== undefined) d.color = c;
137
- }
138
- if (/font-weight\s*:\s*(?:bold|bolder|[6-9]00)/.test(css)) d.bold = true;
139
- if (/font-style\s*:\s*italic/.test(css)) d.italic = true;
140
- if (/text-decoration[^;]*:\s*[^;]*underline/.test(css)) d.underline = true;
141
- }
142
- if (name === 'font') {
143
- const fc = /\bcolor\s*=\s*["']([^"']+)["']/i.exec(attrs);
144
- if (fc !== null) {
145
- const c = parseColor(fc[1]);
146
- if (c !== undefined) d.color = c;
147
- }
148
- }
149
- return d;
150
- }
151
-
152
- /** Flatten a style stack (last color / any true flag wins). */
153
- function compose(stack: readonly Style[]): Style {
154
- const out: Style = {};
155
- for (const s of stack) {
156
- if (s.bold) out.bold = true;
157
- if (s.italic) out.italic = true;
158
- if (s.underline) out.underline = true;
159
- if (s.color !== undefined) out.color = s.color;
160
- }
161
- return out;
162
- }
163
-
164
- const BLOCK = new Set([
165
- 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'table', 'ul', 'ol',
166
- 'blockquote', 'section', 'header', 'footer', 'br', 'hr', 'article', 'main', 'nav', 'aside',
167
- ]);
168
-
169
- /** Parse an HTML body into paragraphs (split on block elements) of styled words,
170
- * preserving whether each word was space-separated from the previous one. */
171
- function htmlToParagraphs(html: string): Word[][] {
172
- const paras: Word[][] = [];
173
- let cur: Word[] = [];
174
- let pendingSpace = false; // a space boundary carried across tag boundaries
175
- const flush = (): void => {
176
- if (cur.length > 0) {
177
- paras.push(cur);
178
- cur = [];
179
- }
180
- pendingSpace = false;
181
- };
182
- const stack: Style[] = [];
183
- const hrefs: (string | null)[] = [];
184
- const re = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>|([^<]+)/g;
185
- let m: RegExpExecArray | null;
186
- while ((m = re.exec(html)) !== null) {
187
- const raw = m[4];
188
- if (raw !== undefined) {
189
- const norm = decodeEntities(raw).replace(/\s+/g, ' ').trim();
190
- if (norm.length === 0) {
191
- if (/\s/.test(raw)) pendingSpace = true;
192
- continue;
193
- }
194
- const leading = /^\s/.test(raw) || pendingSpace;
195
- const st = compose(stack);
196
- const words = norm.split(' ');
197
- for (let i = 0; i < words.length; i++) {
198
- cur.push({ text: words[i], style: st, spaceBefore: cur.length > 0 && (i > 0 || leading) });
199
- }
200
- pendingSpace = /\s$/.test(raw);
201
- continue;
202
- }
203
- const closing = m[1] === '/';
204
- const name = m[2].toLowerCase();
205
- const attrs = m[3] ?? '';
206
- if (BLOCK.has(name)) {
207
- flush();
208
- if (/^h[1-6]$/.test(name)) {
209
- if (closing) stack.pop();
210
- else stack.push(styleFromTag(name, attrs));
211
- }
212
- continue;
213
- }
214
- if (closing) {
215
- stack.pop();
216
- if (name === 'a') {
217
- const href = hrefs.pop();
218
- if (href) {
219
- cur.push({
220
- text: `(${href})`,
221
- style: { ...compose(stack), color: NAMED.gray },
222
- spaceBefore: cur.length > 0,
223
- });
224
- }
225
- }
226
- } else {
227
- stack.push(styleFromTag(name, attrs));
228
- if (name === 'a') {
229
- const hm = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs);
230
- hrefs.push(hm !== null ? hm[1] : null);
231
- }
232
- }
233
- }
234
- flush();
235
- return paras;
236
- }
237
-
238
- /** Apply a style to a string as terminal escapes (no-op when color is unsupported). */
239
- function paint(style: Style, s: string): string {
240
- if (!pc.isColorSupported) return s;
241
- let open = '';
242
- if (style.bold) open += '\x1b[1m';
243
- if (style.italic) open += '\x1b[3m';
244
- if (style.underline) open += '\x1b[4m';
245
- if (style.color !== undefined) open += `\x1b[38;2;${style.color[0]};${style.color[1]};${style.color[2]}m`;
246
- return open === '' ? s : `${open}${s}\x1b[0m`;
247
- }
248
-
249
- /** Word-wrap styled words to `width` VISIBLE columns; over-long words (URLs) overflow.
250
- * Honors each word's `spaceBefore` so attached punctuation is not pushed off. */
251
- function wrapWords(words: readonly Word[], width: number): Word[][] {
252
- const lines: Word[][] = [];
253
- let line: Word[] = [];
254
- let len = 0;
255
- for (const w of words) {
256
- const sep = line.length > 0 && w.spaceBefore ? 1 : 0;
257
- if (line.length > 0 && len + sep + w.text.length > width) {
258
- lines.push(line);
259
- line = [w];
260
- len = w.text.length;
261
- } else {
262
- line.push(w);
263
- len += sep + w.text.length;
264
- }
265
- }
266
- if (line.length > 0) lines.push(line);
267
- return lines;
268
- }
269
-
270
- /**
271
- * Render an HTML body to styled terminal lines (visible width <= `width`). Empty
272
- * strings mark blank separator lines between paragraphs. Exported for tests.
273
- */
274
- export function renderHtmlBody(html: string, width: number = WIDTH): string[] {
275
- const paras = htmlToParagraphs(html);
276
- const out: string[] = [];
277
- for (let p = 0; p < paras.length; p++) {
278
- if (p > 0) out.push(''); // one blank line between paragraphs
279
- for (const line of wrapWords(paras[p], width)) {
280
- out.push(
281
- line.map((w, i) => (i > 0 && w.spaceBefore ? ' ' : '') + paint(w.style, w.text)).join(''),
282
- );
283
- }
284
- }
285
- return out;
286
- }
287
-
288
- /** Word-wrap PLAIN text (the fallback when there is no HTML body). */
289
- function wrap(text: string, width: number): string[] {
290
- const out: string[] = [];
291
- for (const para of text.split('\n')) {
292
- if (para.length === 0) {
293
- out.push('');
294
- continue;
295
- }
296
- let line = '';
297
- for (const word of para.split(' ')) {
298
- if (line.length === 0) line = word;
299
- else if (line.length + 1 + word.length <= width) line += ' ' + word;
300
- else {
301
- out.push(line);
302
- line = word;
303
- }
304
- }
305
- out.push(line);
306
- }
307
- return out;
308
- }
309
-
310
- /**
311
- * The full ASCII box for one dev email. `status` is a short label for the header
312
- * (e.g. `not sent, no provider`, `sent`, `deduped`). The body is rendered from the
313
- * HTML with color + font styling when present, else from the plain-text body.
314
- */
315
- export function renderEmailConsole(parsed: ParsedEmail, status: string): string {
316
- const bar = pc.dim(' │ ');
317
- const rule = (label: string): string => {
318
- const head = label.length > 0 ? `─ ${label} ` : '─';
319
- const fill = '─'.repeat(Math.max(0, WIDTH + 2 - head.length));
320
- return pc.dim(` ├${head}${fill}`);
321
- };
322
- const purpose = parsed.purpose.length > 0 ? ` · ${parsed.purpose}` : '';
323
- const header = `─ ✉ ${pc.bold('Email')} ${pc.dim(`(dev${purpose} · ${status})`)} `;
324
- const topFill = '─'.repeat(Math.max(0, WIDTH + 2 - `─ ✉ Email (dev${purpose} · ${status}) `.length));
325
-
326
- const lines: string[] = [];
327
- lines.push(pc.dim(` ┌${header}${topFill}`));
328
- lines.push(bar + pc.dim('To: ') + parsed.to);
329
- lines.push(bar + pc.dim('Subject: ') + parsed.subject);
330
- lines.push(pc.dim(' │'));
331
-
332
- const bodyLines =
333
- parsed.html.length > 0 ? renderHtmlBody(parsed.html, WIDTH) : wrap(parsed.body, WIDTH);
334
- for (const l of bodyLines) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
335
-
336
- const action = emailAction(parsed);
337
- if (action !== null) {
338
- if (action.kind === 'link') {
339
- lines.push(rule('🔗 click to continue'));
340
- lines.push(bar + pc.cyan(pc.underline(action.value)));
341
- } else {
342
- lines.push(rule('🔢 code'));
343
- lines.push(bar + pc.bold(pc.yellow(action.value)));
344
- }
345
- }
346
- lines.push(pc.dim(` └${'─'.repeat(WIDTH + 2)}`));
347
- return lines.join('\n');
348
- }
@@ -1,155 +0,0 @@
1
- /**
2
- * The dev / self-host email service — the full edge pipeline in Node, reusable
3
- * by the future self-host runtime (this folder has NO Vite/devserver coupling).
4
- *
5
- * Split for the sync-wasm constraint: `prepare()` is SYNCHRONOUS (parse +
6
- * validate + dedup + budget + per-recipient cap) and returns the terminal status
7
- * to hand the guest now; `deliver()` is ASYNC (the actual provider send). A
8
- * synchronous host (the dev server) fires `deliver()` and returns `Sent`
9
- * optimistically; an async-capable host (a future self-host) can `await deliver()`
10
- * for the true status.
11
- */
12
- import { loadEnvFiles } from '../config/dotenv.js';
13
- import { EmailCaps } from './caps.js';
14
- import { type ResolvedEmailConfig, resolveEmailConfig } from './config.js';
15
- import { type OutboundMessage, sendVia } from './providers.js';
16
- import { EmailStatus } from './status.js';
17
- import { validRecipient } from './validate.js';
18
- import { type ParsedEmail, parseEmailBlob } from './wire.js';
19
-
20
- import type { EmailBackendConfig } from 'toiljs/shared';
21
-
22
- export { EmailStatus } from './status.js';
23
- export type { ResolvedEmailConfig } from './config.js';
24
-
25
- export interface PrepareResult {
26
- /** The status to return to the guest now. `Sent` means proceed to `deliver`. */
27
- readonly status: EmailStatus;
28
- /** The parsed message, present iff `status === Sent` (caller should deliver). */
29
- readonly parsed: ParsedEmail | null;
30
- }
31
-
32
- export class NodeEmailService {
33
- private readonly caps = new EmailCaps();
34
- constructor(private readonly config: ResolvedEmailConfig) {}
35
-
36
- /** The configured provider id (for the startup banner / logs). */
37
- get providerLabel(): string {
38
- return `${this.config.provider} (${this.config.from})`;
39
- }
40
-
41
- /**
42
- * Synchronous pre-send: parse the wire blob, validate the recipient, then
43
- * dedup + per-minute/day budget + per-recipient cap (all committing). Returns
44
- * a terminal status (`BadRecipient` / `Deduped` / `Budget` / `RecipientCapped`),
45
- * or `{ status: Sent, parsed }` meaning the caller should `deliver(parsed)`.
46
- */
47
- prepare(blob: Buffer, now: number = Date.now()): PrepareResult {
48
- const parsed = parseEmailBlob(blob);
49
- if (parsed === null || !validRecipient(parsed.to)) {
50
- return { status: EmailStatus.BadRecipient, parsed: null };
51
- }
52
- if (this.caps.isDuplicate(parsed.to, parsed.purpose, now)) {
53
- return { status: EmailStatus.Deduped, parsed: null };
54
- }
55
- if (!this.caps.budgetOk(this.config.maxPerMin, this.config.maxPerDay, now)) {
56
- return { status: EmailStatus.Budget, parsed: null };
57
- }
58
- if (!this.caps.recipientOk(parsed.to, this.config.maxPerRecipientPerHour, now)) {
59
- return { status: EmailStatus.RecipientCapped, parsed: null };
60
- }
61
- return { status: EmailStatus.Sent, parsed };
62
- }
63
-
64
- /** Actually send via the configured provider; resolves to the real status. */
65
- deliver(parsed: ParsedEmail): Promise<EmailStatus> {
66
- const msg: OutboundMessage = {
67
- from: this.config.from,
68
- to: parsed.to,
69
- subject: parsed.subject,
70
- body: parsed.body,
71
- html: parsed.html,
72
- };
73
- return sendVia(this.config, msg);
74
- }
75
- }
76
-
77
- // --- Test-only capture seam ---------------------------------------------------
78
-
79
- /**
80
- * A message the dev email pipeline decided to send (a real provider send, or the
81
- * unconfigured stub), captured for TESTS ONLY. The built-in auth controller
82
- * delivers its one-time confirm/reset tokens via email, so an e2e test reads the
83
- * token straight out of the emitted link here. This is NOT a production path —
84
- * the edge never imports this module, and real sends are unchanged.
85
- */
86
- export interface SentEmail {
87
- readonly to: string;
88
- readonly subject: string;
89
- /** Plain-text body (the confirm/reset link appears here and in `html`). */
90
- readonly text: string;
91
- readonly html: string;
92
- /** Short non-PII tag the guest passed (e.g. `verify`, `reset`). */
93
- readonly purpose: string;
94
- }
95
-
96
- /** Bounded ring of the most-recent dev sends (test seam). */
97
- export const __sentEmails: SentEmail[] = [];
98
- const SENT_EMAILS_MAX = 256;
99
-
100
- /**
101
- * Record one dev send into {@link __sentEmails}. Called at the exact point the
102
- * dev pipeline decides to send/stub, so it captures precisely the messages the
103
- * guest emitted (and nothing it dropped/deduped).
104
- */
105
- export function recordSentEmail(msg: ParsedEmail): void {
106
- __sentEmails.push({
107
- to: msg.to,
108
- subject: msg.subject,
109
- text: msg.body,
110
- html: msg.html,
111
- purpose: msg.purpose,
112
- });
113
- while (__sentEmails.length > SENT_EMAILS_MAX) __sentEmails.shift();
114
- }
115
-
116
- /** Clear the captured dev-send ring (tests call this between cases). */
117
- export function __clearSentEmails(): void {
118
- __sentEmails.length = 0;
119
- }
120
-
121
- // --- Process-level singleton (one project per dev/self-host process) ----------
122
-
123
- let service: NodeEmailService | null = null;
124
-
125
- export interface EmailInitResult {
126
- /** The service, or `null` when email is unconfigured / invalid. */
127
- readonly service: NodeEmailService | null;
128
- /** A note for the startup banner: the provider label, or a config warning. */
129
- readonly note: string | null;
130
- }
131
-
132
- /**
133
- * Build the email service from the `toil.config.ts` `email` section + the
134
- * project's `.env.secrets` (`TOIL_EMAIL_*`, holding the API key) and install it
135
- * as the process singleton. Call once at startup. Returns a startup note.
136
- */
137
- export function initEmailService(
138
- root: string,
139
- cfgEmail: EmailBackendConfig | null | undefined,
140
- ): EmailInitResult {
141
- const reserved = loadEnvFiles(root).reserved;
142
- const { config, warning } = resolveEmailConfig(cfgEmail, reserved);
143
- service = config === null ? null : new NodeEmailService(config);
144
- return { service, note: service === null ? warning : service.providerLabel };
145
- }
146
-
147
- /** The installed email service, or `null` when email is not configured. */
148
- export function getEmailService(): NodeEmailService | null {
149
- return service;
150
- }
151
-
152
- /** Reset the singleton (tests). */
153
- export function resetEmailService(): void {
154
- service = null;
155
- }
@@ -1,131 +0,0 @@
1
- /**
2
- * Provider transports for the dev / self-host mailer, mirroring the edge's
3
- * `mailer.rs`: Resend over `fetch` (`send_resend`) and SMTP over nodemailer
4
- * (`send_smtp`). Both retry transient failures with capped backoff; a permanent
5
- * rejection is terminal (`ProviderError`).
6
- *
7
- * nodemailer is imported lazily, so a Resend-only project never loads it.
8
- */
9
- import { EmailStatus } from './status.js';
10
- import type { ResolvedEmailConfig } from './config.js';
11
-
12
- const MAX_ATTEMPTS = 3;
13
- const BACKOFF_BASE_MS = 200;
14
- const POST_TIMEOUT_MS = 10_000;
15
-
16
- /** The concrete parts of one send (already validated + rendered). */
17
- export interface OutboundMessage {
18
- readonly from: string;
19
- readonly to: string;
20
- readonly subject: string;
21
- /** Plain-text body (may be empty when only `html` is set). */
22
- readonly body: string;
23
- /** Optional HTML body (empty = plain-text send). */
24
- readonly html: string;
25
- }
26
-
27
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
28
-
29
- /**
30
- * Classify an HTTP status (or `null` for a transport failure): 2xx → `Sent`,
31
- * 4xx → terminal `ProviderError` (a bad address keeps being bad), else
32
- * (5xx / transport) → retry.
33
- */
34
- function classifyHttp(status: number | null): EmailStatus | 'retry' {
35
- if (status !== null && status >= 200 && status < 300) return EmailStatus.Sent;
36
- if (status !== null && status >= 400 && status < 500) return EmailStatus.ProviderError;
37
- return 'retry';
38
- }
39
-
40
- /** Drive one Resend POST with retry. */
41
- export async function sendResend(
42
- cfg: ResolvedEmailConfig,
43
- msg: OutboundMessage,
44
- ): Promise<EmailStatus> {
45
- const payload: Record<string, unknown> = {
46
- from: msg.from,
47
- to: [msg.to],
48
- subject: msg.subject,
49
- };
50
- if (msg.body.length > 0) payload.text = msg.body;
51
- if (msg.html.length > 0) payload.html = msg.html;
52
-
53
- let backoff = BACKOFF_BASE_MS;
54
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
55
- let status: number | null = null;
56
- try {
57
- const res = await fetch('https://api.resend.com/emails', {
58
- method: 'POST',
59
- headers: {
60
- authorization: `Bearer ${cfg.apiKey}`,
61
- 'content-type': 'application/json',
62
- },
63
- body: JSON.stringify(payload),
64
- signal: AbortSignal.timeout(POST_TIMEOUT_MS),
65
- });
66
- status = res.status;
67
- } catch {
68
- status = null; // transport error → retry
69
- }
70
- const verdict = classifyHttp(status);
71
- if (verdict !== 'retry') return verdict;
72
- if (attempt === MAX_ATTEMPTS) break;
73
- await sleep(backoff);
74
- backoff *= 2;
75
- }
76
- return EmailStatus.ProviderError;
77
- }
78
-
79
- /** Send one email over SMTP (nodemailer) with retry. */
80
- export async function sendSmtp(
81
- cfg: ResolvedEmailConfig,
82
- msg: OutboundMessage,
83
- ): Promise<EmailStatus> {
84
- const smtp = cfg.smtp;
85
- if (smtp === undefined) return EmailStatus.ProviderError; // resolve() guarantees Some for smtp
86
-
87
- let transporter: import('nodemailer').Transporter;
88
- try {
89
- const nodemailer = await import('nodemailer');
90
- transporter = nodemailer.createTransport({
91
- host: smtp.host,
92
- port: smtp.port,
93
- secure: smtp.port === 465, // 465 = implicit TLS; else STARTTLS
94
- // Auth only when there is a credential; a local/auth-less relay takes none.
95
- auth: cfg.apiKey.length > 0 ? { user: smtp.user, pass: cfg.apiKey } : undefined,
96
- connectionTimeout: POST_TIMEOUT_MS,
97
- greetingTimeout: POST_TIMEOUT_MS,
98
- });
99
- } catch {
100
- // nodemailer not installed: SMTP unavailable.
101
- return EmailStatus.ProviderError;
102
- }
103
-
104
- let backoff = BACKOFF_BASE_MS;
105
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
106
- try {
107
- await transporter.sendMail({
108
- from: msg.from,
109
- to: msg.to,
110
- subject: msg.subject,
111
- text: msg.body.length > 0 ? msg.body : undefined,
112
- html: msg.html.length > 0 ? msg.html : undefined,
113
- });
114
- return EmailStatus.Sent;
115
- } catch (e) {
116
- // SMTP 5xx is a PERMANENT failure (bad auth / rejected recipient):
117
- // terminal. 4xx / transport errors are transient: retry with backoff.
118
- const code = (e as { responseCode?: number }).responseCode;
119
- if (typeof code === 'number' && code >= 500) return EmailStatus.ProviderError;
120
- if (attempt === MAX_ATTEMPTS) break;
121
- await sleep(backoff);
122
- backoff *= 2;
123
- }
124
- }
125
- return EmailStatus.ProviderError;
126
- }
127
-
128
- /** Dispatch to the configured provider. */
129
- export function sendVia(cfg: ResolvedEmailConfig, msg: OutboundMessage): Promise<EmailStatus> {
130
- return cfg.provider === 'resend' ? sendResend(cfg, msg) : sendSmtp(cfg, msg);
131
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * The `i32` status `env::email_send` returns to the guest. Byte-identical to the
3
- * edge's `EmailStatus` (toil-backend `host/email.rs`, `#[repr(i32)]`) and the
4
- * guest `EmailStatus` enum (`server/globals/email.ts`). `Sent` and `Deduped` are
5
- * success; the rest say why it was not delivered and whether a retry could help.
6
- */
7
- export enum EmailStatus {
8
- Sent = 0,
9
- /** No email config (or not enabled). */
10
- Disabled = 1,
11
- /** Per-process minute/day budget exhausted. Retriable later. */
12
- Budget = 2,
13
- /** Per-recipient hourly cap hit. Terminal for this recipient/window. */
14
- RecipientCapped = 3,
15
- /** An identical recent (recipient, purpose) send was collapsed. Treat as sent. */
16
- Deduped = 4,
17
- /** Saturated / queue full. Retriable; back off. */
18
- TryLater = 5,
19
- /** The recipient failed host-side validation (CRLF, multiple addresses, malformed). */
20
- BadRecipient = 6,
21
- /** The provider rejected the send, or transport failed after retries. Terminal. */
22
- ProviderError = 7,
23
- }