toiljs 0.0.92 → 0.0.94

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.
@@ -51,8 +51,13 @@ export interface ClientConfig {
51
51
  readonly outDir?: string;
52
52
  /** Public base path. Default `/`. */
53
53
  readonly base?: string;
54
- /** Dev server port. Default `3000`. */
54
+ /** Port the dev server AND the self-host (`toiljs start`) listen on. Default `3000`.
55
+ * Overridden by the `--port` CLI flag and the `PORT` env var (in that precedence). */
55
56
  readonly port?: number;
57
+ /** Bind host for the dev server AND the self-host. Default `127.0.0.1` (loopback only).
58
+ * Set to `0.0.0.0` (or a specific interface) to accept LAN / public connections.
59
+ * Overridden by the `--host` CLI flag and the `HOST` env var (in that precedence). */
60
+ readonly host?: string;
56
61
  /**
57
62
  * Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
58
63
  * import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
@@ -194,6 +199,8 @@ export interface ResolvedToilConfig {
194
199
  readonly outDir: string;
195
200
  readonly base: string;
196
201
  readonly port: number;
202
+ /** Bind host for the dev server + self-host (`toiljs start`). Default `127.0.0.1`. */
203
+ readonly host: string;
197
204
  /** Whether build-time image optimization (`vite-imagetools`) is enabled. */
198
205
  readonly images: boolean;
199
206
  /** Whether build-time font preloading is enabled. */
@@ -246,7 +253,7 @@ function resolveRuntimePath(): string {
246
253
 
247
254
  /** Finds and loads `toil.config.*` or `toiljs.config.*` from `root`, then resolves defaults. */
248
255
  export async function loadConfig(
249
- opts: { root?: string; port?: number } = {},
256
+ opts: { root?: string; port?: number; host?: string } = {},
250
257
  ): Promise<ResolvedToilConfig> {
251
258
  const root = path.resolve(opts.root ?? process.cwd());
252
259
 
@@ -278,7 +285,12 @@ export async function loadConfig(
278
285
  toilDir: path.join(root, '.toil'),
279
286
  outDir: client.outDir ?? 'build/client',
280
287
  base: client.base ?? '/',
281
- port: opts.port ?? client.port ?? 3000,
288
+ // Port + bind host for the dev server and the self-host, resolved with the
289
+ // precedence: CLI flag (opts) > env (PORT/HOST) > config (client.*) > default.
290
+ // `PORT`/`HOST` are the conventional PaaS env vars; a platform that injects
291
+ // `PORT` (Heroku, Render, Fly, containers) works with zero config.
292
+ port: opts.port ?? envPort() ?? client.port ?? 3000,
293
+ host: opts.host ?? envHost() ?? client.host ?? '127.0.0.1',
282
294
  images: client.images ?? true,
283
295
  fonts: client.fonts ?? true,
284
296
  viewTransitions: client.viewTransitions ?? false,
@@ -299,6 +311,21 @@ export async function loadConfig(
299
311
  };
300
312
  }
301
313
 
314
+ /** The `PORT` env var as a positive integer, or `undefined` when unset/invalid
315
+ * (so it falls through to the config value / default rather than binding to 0). */
316
+ function envPort(): number | undefined {
317
+ const v = process.env.PORT;
318
+ if (v == null || v.trim() === '') return undefined;
319
+ const n = Number(v);
320
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
321
+ }
322
+
323
+ /** The `HOST` env var (trimmed), or `undefined` when unset/blank. */
324
+ function envHost(): string | undefined {
325
+ const v = process.env.HOST;
326
+ return v != null && v.trim() !== '' ? v.trim() : undefined;
327
+ }
328
+
302
329
  const DEV_NODE_MODES: readonly DevNodeMode[] = ['hot', 'regional', 'continental', 'daemon', 'all'];
303
330
 
304
331
  /** A `nodeMode` outside the enum falls back to `all` with a warning (fail-soft:
@@ -845,6 +845,17 @@ async function collectDevCss(server: ViteDevServer): Promise<string> {
845
845
  return css;
846
846
  }
847
847
 
848
+ /** The host to show in a clickable URL: a wildcard bind (`0.0.0.0`/`::`/empty) is
849
+ * not universally openable, so present `localhost` for those; otherwise the host. */
850
+ function displayHost(host: string): string {
851
+ return host === '0.0.0.0' || host === '::' || host === '' ? 'localhost' : host;
852
+ }
853
+
854
+ /** Whether the bind host exposes the server beyond loopback (worth flagging to the user). */
855
+ function isExposedHost(host: string): boolean {
856
+ return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
857
+ }
858
+
848
859
  export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
849
860
  const cfg = await loadConfig(opts);
850
861
  // Server first: build it (regenerating shared/server.ts) before the client dev server starts.
@@ -921,6 +932,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
921
932
  const front = await startDevServer({
922
933
  root: cfg.root,
923
934
  port: cfg.port,
935
+ host: cfg.host,
924
936
  wasmFile: serverWasmFile(cfg.root),
925
937
  // The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
926
938
  // project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
@@ -938,17 +950,21 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
938
950
  server.httpServer?.once('close', () => {
939
951
  void front.close();
940
952
  });
953
+ const shownHost = displayHost(front.host);
941
954
  process.stdout.write(
942
955
  '\n ' +
943
956
  pc.green('➜') +
944
957
  ' ' +
945
958
  pc.bold('Local') +
946
959
  ': ' +
947
- pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
960
+ pc.cyan(`http://${shownHost}:${pc.bold(String(front.port))}/`) +
948
961
  pc.dim(' (wasm server + vite)') +
962
+ (isExposedHost(front.host)
963
+ ? pc.yellow(`\n ⚠ exposed on ${front.host}:${String(front.port)} (not just loopback)`)
964
+ : '') +
949
965
  '\n',
950
966
  );
951
- printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
967
+ printEmailsUrl(cfg, `http://${shownHost}:${String(front.port)}/`);
952
968
 
953
969
  // Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
954
970
  // and the dev server hot-swaps the recompiled wasm module.
@@ -1044,7 +1060,9 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
1044
1060
  daemon: cfg.daemon,
1045
1061
  threads: opts.threads ?? cfg.threads,
1046
1062
  port: cfg.port,
1047
- host: opts.host,
1063
+ // `cfg.host` already folds in the `--host` flag, `HOST` env, and `client.host`
1064
+ // config (CLI > env > config > 127.0.0.1); see loadConfig.
1065
+ host: cfg.host,
1048
1066
  email: cfg.email ?? undefined,
1049
1067
  });
1050
1068
  }
@@ -0,0 +1,134 @@
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,
5
+ * lays it out under a left-barred box, and highlights the ACTION (the one-time
6
+ * link, which carries its token in the `#token=` fragment and so is directly
7
+ * clickable, or the numeric 2FA code). Dev-only; the production edge never renders
8
+ * or logs token material.
9
+ *
10
+ * The box uses a LEFT bar plus full top/bottom rules (no right border), so colored
11
+ * spans never break column alignment and long URLs stay on one unbroken, clickable
12
+ * line.
13
+ */
14
+ import pc from 'picocolors';
15
+
16
+ import { type ParsedEmail } from './wire.js';
17
+
18
+ const WIDTH = 68; // wrap column for body text (URLs are left un-wrapped)
19
+
20
+ /** Decode the handful of HTML entities our built-in templates can emit. */
21
+ function decodeEntities(s: string): string {
22
+ return s
23
+ .replace(/&nbsp;/g, ' ')
24
+ .replace(/&amp;/g, '&')
25
+ .replace(/&lt;/g, '<')
26
+ .replace(/&gt;/g, '>')
27
+ .replace(/&quot;/g, '"')
28
+ .replace(/&#0*39;|&apos;/g, "'")
29
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => String.fromCodePoint(parseInt(h, 16)))
30
+ .replace(/&#(\d+);/g, (_m, d: string) => String.fromCodePoint(parseInt(d, 10)));
31
+ }
32
+
33
+ /**
34
+ * A plain-text rendering of an HTML email body: anchors become `text (url)` so
35
+ * the URL stays visible + clickable, block elements become line breaks, remaining
36
+ * tags are stripped, entities decoded, and whitespace collapsed.
37
+ */
38
+ export function htmlToText(html: string): string {
39
+ let s = html;
40
+ // <a href="URL">TEXT</a> -> "TEXT (URL)" (or just URL when text == url / empty)
41
+ s = s.replace(
42
+ /<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
43
+ (_m, href: string, inner: string) => {
44
+ const t = inner.replace(/<[^>]+>/g, '').trim();
45
+ return t.length > 0 && t !== href ? `${t} (${href})` : href;
46
+ },
47
+ );
48
+ // block-level tags -> newlines (open OR close), <br> -> newline
49
+ s = s.replace(/<br\s*\/?>/gi, '\n');
50
+ s = s.replace(/<\/?(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|section|header|footer)\b[^>]*>/gi, '\n');
51
+ // strip whatever tags remain
52
+ s = s.replace(/<[^>]+>/g, '');
53
+ s = decodeEntities(s);
54
+ // collapse intra-line whitespace, trim each line, cap blank runs at one
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
+ /** Word-wrap to `width`; words longer than `width` (URLs, tokens) overflow rather
76
+ * than being hard-split, so they stay intact and clickable. */
77
+ function wrap(text: string, width: number): string[] {
78
+ const out: string[] = [];
79
+ for (const para of text.split('\n')) {
80
+ if (para.length === 0) {
81
+ out.push('');
82
+ continue;
83
+ }
84
+ let line = '';
85
+ for (const word of para.split(' ')) {
86
+ if (line.length === 0) line = word;
87
+ else if (line.length + 1 + word.length <= width) line += ' ' + word;
88
+ else {
89
+ out.push(line);
90
+ line = word;
91
+ }
92
+ }
93
+ out.push(line);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * The full ASCII box for one dev email. `status` is a short label for the header
100
+ * (e.g. `not sent - no provider`, `sent`, `deduped`).
101
+ */
102
+ export function renderEmailConsole(parsed: ParsedEmail, status: string): string {
103
+ const bar = pc.dim(' │ ');
104
+ const rule = (label: string): string => {
105
+ const head = label.length > 0 ? `─ ${label} ` : '─';
106
+ const fill = '─'.repeat(Math.max(0, WIDTH + 2 - head.length));
107
+ return pc.dim(` ├${head}${fill}`);
108
+ };
109
+ const purpose = parsed.purpose.length > 0 ? ` · ${parsed.purpose}` : '';
110
+ const header = `─ ✉ ${pc.bold('Email')} ${pc.dim(`(dev${purpose} · ${status})`)} `;
111
+ const topFill = '─'.repeat(Math.max(0, WIDTH + 2 - `─ ✉ Email (dev${purpose} · ${status}) `.length));
112
+
113
+ const lines: string[] = [];
114
+ lines.push(pc.dim(` ┌${header}${topFill}`));
115
+ lines.push(bar + pc.dim('To: ') + parsed.to);
116
+ lines.push(bar + pc.dim('Subject: ') + parsed.subject);
117
+ lines.push(pc.dim(' │'));
118
+
119
+ const bodyText = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
120
+ for (const l of wrap(bodyText, WIDTH)) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
121
+
122
+ const action = emailAction(parsed);
123
+ if (action !== null) {
124
+ if (action.kind === 'link') {
125
+ lines.push(rule('🔗 click to continue'));
126
+ lines.push(bar + pc.cyan(pc.underline(action.value)));
127
+ } else {
128
+ lines.push(rule('🔢 code'));
129
+ lines.push(bar + pc.bold(pc.yellow(action.value)));
130
+ }
131
+ }
132
+ lines.push(pc.dim(` └${'─'.repeat(WIDTH + 2)}`));
133
+ return lines.join('\n');
134
+ }
@@ -22,7 +22,8 @@ import { ratelimitCheck } from '../config/ratelimit.js';
22
22
  import { buildAnalyticsImports } from '../analytics/index.js';
23
23
  import { buildDatabaseImports, type DbDevState, freshDbState } from '../db/index.js';
24
24
  import { EmailStatus, getEmailService, recordSentEmail } from '../email/index.js';
25
- import { parseEmailBlob } from '../email/wire.js';
25
+ import { renderEmailConsole } from '../email/console.js';
26
+ import { type ParsedEmail, parseEmailBlob } from '../email/wire.js';
26
27
  import { buildCryptoImports, type CryptoState, freshCryptoState } from './crypto.js';
27
28
 
28
29
  /** Limits identical to the edge's `set_header` / `respond_file` bounds. */
@@ -204,6 +205,14 @@ function envLookup(
204
205
  * confirm/reset tests find their tokens whichever import the guest used. Mirrors
205
206
  * the edge's `email_send_import.rs`.
206
207
  */
208
+ /** Render the dev EMAIL EMULATOR box (from the HTML) so confirm/reset/2FA flows are
209
+ * testable without a real inbox. Skipped under vitest (tests read the
210
+ * `__sentEmails` seam), where a box per email would just be noise. */
211
+ function previewDevEmail(parsed: ParsedEmail, status: string): void {
212
+ if (process.env.VITEST) return;
213
+ process.stdout.write(renderEmailConsole(parsed, status) + '\n');
214
+ }
215
+
207
216
  function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
208
217
  const raw = readBytes(ref, reqPtr, reqLen);
209
218
  const svc = getEmailService();
@@ -212,6 +221,8 @@ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
212
221
  if (parsed !== null) recordSentEmail(parsed); // dev test seam
213
222
  const to = parsed?.to ?? '<unparsed>';
214
223
  process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
224
+ // Draw the email so the flow is testable with no mailer configured.
225
+ if (parsed !== null) previewDevEmail(parsed, 'not sent · no provider');
215
226
  return EmailStatus.Sent;
216
227
  }
217
228
  const { status, parsed } = svc.prepare(raw);
@@ -220,6 +231,7 @@ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
220
231
  return status;
221
232
  }
222
233
  recordSentEmail(parsed); // dev test seam
234
+ previewDevEmail(parsed, 'delivering via provider'); // shown even with a real provider
223
235
  void svc
224
236
  .deliver(parsed)
225
237
  .then((s) => {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * loadConfig host/port resolution: the dev server AND the self-host (`toiljs start`)
3
+ * bind to a host+port resolved with the precedence CLI flag > env (PORT/HOST) >
4
+ * config (client.host/port) > default (127.0.0.1:3000). These lock that precedence
5
+ * and the env parsing (a blank/garbage PORT must fall through, never bind to 0).
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+
13
+ import { loadConfig } from '../src/compiler/config.js';
14
+
15
+ // An empty dir with no toil.config so loadConfig exercises the env/opts/default path.
16
+ const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-cfg-'));
17
+
18
+ describe('loadConfig host/port precedence', () => {
19
+ const savedPort = process.env.PORT;
20
+ const savedHost = process.env.HOST;
21
+ afterEach(() => {
22
+ if (savedPort === undefined) delete process.env.PORT;
23
+ else process.env.PORT = savedPort;
24
+ if (savedHost === undefined) delete process.env.HOST;
25
+ else process.env.HOST = savedHost;
26
+ });
27
+
28
+ it('defaults to 127.0.0.1:3000 with no flag / env / config', async () => {
29
+ delete process.env.PORT;
30
+ delete process.env.HOST;
31
+ const cfg = await loadConfig({ root: emptyRoot });
32
+ expect(cfg.port).toBe(3000);
33
+ expect(cfg.host).toBe('127.0.0.1');
34
+ });
35
+
36
+ it('reads the PORT / HOST env vars', async () => {
37
+ process.env.PORT = '8080';
38
+ process.env.HOST = '0.0.0.0';
39
+ const cfg = await loadConfig({ root: emptyRoot });
40
+ expect(cfg.port).toBe(8080);
41
+ expect(cfg.host).toBe('0.0.0.0');
42
+ });
43
+
44
+ it('CLI opts win over env (CLI > env)', async () => {
45
+ process.env.PORT = '8080';
46
+ process.env.HOST = '0.0.0.0';
47
+ const cfg = await loadConfig({ root: emptyRoot, port: 4321, host: '192.168.1.5' });
48
+ expect(cfg.port).toBe(4321);
49
+ expect(cfg.host).toBe('192.168.1.5');
50
+ });
51
+
52
+ it('a blank / non-numeric PORT env falls through to the default (never binds to 0)', async () => {
53
+ process.env.PORT = '';
54
+ delete process.env.HOST;
55
+ expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
56
+ process.env.PORT = 'not-a-number';
57
+ expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
58
+ });
59
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Dev email emulator (src/devserver/email/console.ts): the ASCII-drawn box + the
3
+ * HTML->text conversion + action (link/code) extraction that make confirm/reset/2FA
4
+ * flows testable in `toiljs dev` without a real inbox.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { emailAction, htmlToText, renderEmailConsole } from '../src/devserver/email/console.js';
9
+
10
+ const confirmEmail = {
11
+ to: 'bob@gmail.com',
12
+ subject: 'Confirm your account',
13
+ purpose: 'verify',
14
+ body: 'Confirm your account by opening this link:\nhttp://localhost:3000/confirm#token=abc123\n',
15
+ html:
16
+ '<p>Confirm your account by clicking the link below:</p>' +
17
+ '<p><a href="http://localhost:3000/confirm#token=abc123">Confirm my account</a></p>',
18
+ };
19
+
20
+ /** picocolors may or may not emit ANSI depending on TTY; strip it for assertions. */
21
+ const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '');
22
+
23
+ describe('dev email emulator', () => {
24
+ it('htmlToText: anchors become "text (url)", blocks become newlines, entities decode', () => {
25
+ const t = htmlToText('<p>Hi &amp; welcome</p><p><a href="https://x.com/a?b=1">Open</a></p>');
26
+ expect(t).toContain('Hi & welcome');
27
+ expect(t).toContain('Open (https://x.com/a?b=1)');
28
+ });
29
+
30
+ it('emailAction extracts the link from a confirm email', () => {
31
+ expect(emailAction(confirmEmail)).toEqual({
32
+ kind: 'link',
33
+ value: 'http://localhost:3000/confirm#token=abc123',
34
+ });
35
+ });
36
+
37
+ it('emailAction extracts a 2FA code when there is no link', () => {
38
+ const twofa = { to: 'x', subject: 's', purpose: '2fa', body: 'Your code is 481920.', html: '' };
39
+ expect(emailAction(twofa)).toEqual({ kind: 'code', value: '481920' });
40
+ });
41
+
42
+ it('emailAction falls back to the HTML body when the text body is empty', () => {
43
+ expect(emailAction({ ...confirmEmail, body: '' })?.value).toBe(
44
+ 'http://localhost:3000/confirm#token=abc123',
45
+ );
46
+ });
47
+
48
+ it('renderEmailConsole draws a box with recipient, subject, and the clickable link', () => {
49
+ const box = plain(renderEmailConsole(confirmEmail, 'not sent'));
50
+ expect(box).toContain('bob@gmail.com');
51
+ expect(box).toContain('Confirm your account');
52
+ expect(box).toContain('http://localhost:3000/confirm#token=abc123');
53
+ expect(box).toContain('┌');
54
+ expect(box).toContain('└');
55
+ });
56
+ });