toiljs 0.0.45 → 0.0.47

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.
@@ -228,6 +228,33 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
228
228
  });
229
229
  }
230
230
 
231
+ /**
232
+ * Make `Ctrl+C` actually kill the dev server. Without this the process can hang
233
+ * on shutdown (the native uWebSockets listener / Vite's watcher don't always
234
+ * close promptly), so an old `toiljs dev` is left ORPHANED — still watching and
235
+ * rebuilding — and the next run races it (parallel double rebuilds), while the
236
+ * console is left with a hidden cursor. On SIGINT/SIGTERM we restore the cursor,
237
+ * close the servers, and force-exit after a short grace period no matter what.
238
+ */
239
+ function installDevShutdown(close: () => Promise<void> | void): void {
240
+ let closing = false;
241
+ const shutdown = (): void => {
242
+ if (closing) return;
243
+ closing = true;
244
+ // Restore the cursor (anything that hid it leaves the terminal odd on exit).
245
+ process.stdout.write('\x1b[?25h');
246
+ process.stdout.write(pc.dim('\n shutting down dev server…') + '\n');
247
+ // Force-exit even if a server hangs on close (the orphan-prevention).
248
+ const hard = setTimeout(() => process.exit(0), 1500);
249
+ hard.unref();
250
+ Promise.resolve()
251
+ .then(close)
252
+ .catch(() => {})
253
+ .finally(() => process.exit(0));
254
+ };
255
+ for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, shutdown);
256
+ }
257
+
231
258
  /** The server wasm artifact path from the toilconfig `release` target (toilscript's output). */
232
259
  function serverWasmFile(root: string): string {
233
260
  let outFile = 'build/server/release.wasm';
@@ -268,6 +295,23 @@ export interface ToilCommandOptions {
268
295
  readonly serverOnly?: boolean;
269
296
  }
270
297
 
298
+ /** Prints the email-preview URL under the dev banner, when the project has an
299
+ * `emails/` folder. `localUrl` is the resolved base (ends in `/`); skipped if
300
+ * the server didn't report one. */
301
+ function printEmailsUrl(cfg: ResolvedToilConfig, localUrl: string | undefined): void {
302
+ if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails'))) return;
303
+ process.stdout.write(
304
+ ' ' +
305
+ pc.green('✉') +
306
+ ' ' +
307
+ pc.bold('Emails') +
308
+ ': ' +
309
+ pc.cyan(`${localUrl}__toil/emails`) +
310
+ pc.dim(' (preview)') +
311
+ '\n',
312
+ );
313
+ }
314
+
271
315
  /**
272
316
  * Starts the dev server. Client-only projects get the plain Vite dev server on
273
317
  * the configured port, unchanged. Projects with a server target
@@ -293,6 +337,8 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
293
337
  const server = await createServer(await createViteConfig(cfg));
294
338
  await server.listen();
295
339
  server.printUrls();
340
+ printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
341
+ installDevShutdown(() => server.close());
296
342
  return server;
297
343
  }
298
344
 
@@ -325,10 +371,15 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
325
371
  pc.dim(' (wasm server + vite)') +
326
372
  '\n',
327
373
  );
374
+ printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
328
375
 
329
376
  // Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
330
377
  // and the dev server hot-swaps the recompiled wasm module.
331
378
  watchServer(cfg, server.watcher);
379
+ installDevShutdown(async () => {
380
+ await front.close();
381
+ await server.close();
382
+ });
332
383
  return server;
333
384
  }
334
385
 
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { type Plugin, version as viteVersion } from 'vite';
9
9
 
10
10
  import { AiProvider, type DevtoolsAiConfig, type ResolvedToilConfig } from './config.js';
11
+ import { emailsVersion, listEmails, previewShellHtml, renderEmailByName } from './email-preview.js';
11
12
  import { generate } from './generate.js';
12
13
  import { scanRoutes } from './routes.js';
13
14
 
@@ -58,7 +59,9 @@ async function aiComplete(ai: DevtoolsAiConfig, prompt: string): Promise<string>
58
59
  /** Reads a package's version resolved from `<fromDir>`, or 'unknown'. */
59
60
  function depVersion(fromDir: string, name: string): string {
60
61
  try {
61
- const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(`${name}/package.json`);
62
+ const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(
63
+ `${name}/package.json`,
64
+ );
62
65
  const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
63
66
  return raw.version ?? 'unknown';
64
67
  } catch {
@@ -69,7 +72,12 @@ function depVersion(fromDir: string, name: string): string {
69
72
  /** toiljs's own version (package.json two levels up from build/compiler). */
70
73
  function frameworkVersion(): string {
71
74
  try {
72
- const p = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
75
+ const p = path.resolve(
76
+ path.dirname(fileURLToPath(import.meta.url)),
77
+ '..',
78
+ '..',
79
+ 'package.json',
80
+ );
73
81
  const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as { version?: string };
74
82
  return raw.version ?? '0.0.0';
75
83
  } catch {
@@ -231,7 +239,9 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
231
239
  const parsed = JSON.parse(body || '{}') as { prompt?: string };
232
240
  // Cap the prompt actually forwarded upstream (independent of the raw-body cap).
233
241
  const prompt =
234
- typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
242
+ typeof parsed.prompt === 'string'
243
+ ? parsed.prompt.slice(0, 16000)
244
+ : '';
235
245
  const text = await aiComplete(ai, prompt);
236
246
  res.setHeader('content-type', 'application/json');
237
247
  res.end(JSON.stringify({ text }));
@@ -243,7 +253,11 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
243
253
  );
244
254
  res.statusCode = 500;
245
255
  res.setHeader('content-type', 'application/json');
246
- res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
256
+ res.end(
257
+ JSON.stringify({
258
+ error: 'AI request failed (see dev server logs).',
259
+ }),
260
+ );
247
261
  }
248
262
  })();
249
263
  });
@@ -291,6 +305,76 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
291
305
  }
292
306
  });
293
307
 
308
+ // Email preview tool (dev only). `/__toil/emails` -> a standalone page that lists
309
+ // `emails/*.tsx`, renders the selected one through the live SSR server (so edits and
310
+ // imported `client/*` CSS show), and live-refreshes by polling `/version`.
311
+ // Sub-paths are registered before the page so connect's prefix match resolves them first.
312
+ server.middlewares.use('/__toil/emails/list', (req, res) => {
313
+ if (isCrossSiteRequest(req.headers)) {
314
+ res.statusCode = 403;
315
+ res.end();
316
+ return;
317
+ }
318
+ res.setHeader('content-type', 'application/json');
319
+ res.end(JSON.stringify(listEmails(cfg)));
320
+ });
321
+ server.middlewares.use('/__toil/emails/render', (req, res) => {
322
+ if (isCrossSiteRequest(req.headers)) {
323
+ res.statusCode = 403;
324
+ res.end();
325
+ return;
326
+ }
327
+ void (async () => {
328
+ try {
329
+ const name = new URL(req.url ?? '', 'http://localhost').searchParams.get(
330
+ 'name',
331
+ );
332
+ if (!name) {
333
+ res.statusCode = 400;
334
+ res.end();
335
+ return;
336
+ }
337
+ const rendered = await renderEmailByName(server, cfg, name);
338
+ if (!rendered) {
339
+ res.statusCode = 404;
340
+ res.end();
341
+ return;
342
+ }
343
+ res.setHeader('content-type', 'application/json');
344
+ res.end(JSON.stringify(rendered));
345
+ } catch (e) {
346
+ // Log the detail to the dev's terminal; the page shows a generic message.
347
+ process.stderr.write(
348
+ `toil: /__toil/emails/render failed: ${e instanceof Error ? e.message : String(e)}\n`,
349
+ );
350
+ res.statusCode = 500;
351
+ res.end();
352
+ }
353
+ })();
354
+ });
355
+ // A tiny mtime fingerprint of `emails/*` + client CSS the page polls (~1s) to detect
356
+ // edits. Polling (not SSE) because the wasm dev server proxies `/__toil/*` to Vite by
357
+ // buffering the whole response, so a long-lived stream would hang; a short poll works
358
+ // in every mode.
359
+ server.middlewares.use('/__toil/emails/version', (req, res) => {
360
+ if (isCrossSiteRequest(req.headers)) {
361
+ res.statusCode = 403;
362
+ res.end();
363
+ return;
364
+ }
365
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
366
+ res.end(emailsVersion(cfg));
367
+ });
368
+ server.middlewares.use('/__toil/emails', (req, res) => {
369
+ if (isCrossSiteRequest(req.headers)) {
370
+ res.statusCode = 403;
371
+ res.end();
372
+ return;
373
+ }
374
+ res.setHeader('content-type', 'text/html; charset=utf-8');
375
+ res.end(previewShellHtml());
376
+ });
377
+
294
378
  // Trailing slash so a sibling like `routes-extra/` doesn't match the `routes/` prefix.
295
379
  const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
296
380
  const onChange = (file: string): void => {
@@ -154,6 +154,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
154
154
  alias: {
155
155
  'toiljs/client': cfg.runtimePath,
156
156
  'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
157
+ // `client/*` -> the project's client source dir, so anything (pages, and notably
158
+ // `emails/*.tsx`) can reuse existing client assets, e.g. `import 'client/styles/x.css'`.
159
+ // Vite's string alias matches only `client` or `client/...`, never `toiljs/client`.
160
+ client: cfg.clientAbsDir,
157
161
  // `shared/*` is resolved by sharedResolverPlugin (above) so a missing generated
158
162
  // shared/server.ts gives an actionable error instead of an opaque load failure.
159
163
  ...polyfillShimAliases,
@@ -0,0 +1,68 @@
1
+ import path from 'node:path';
2
+
3
+ import { createServer } from 'vite';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { loadConfig } from '../src/compiler/config';
7
+ import {
8
+ emailsVersion,
9
+ listEmails,
10
+ previewShellHtml,
11
+ renderEmailByName,
12
+ } from '../src/compiler/email-preview';
13
+ import { createViteConfig } from '../src/compiler/vite';
14
+
15
+ const EXAMPLE = path.resolve(__dirname, '../examples/basic');
16
+
17
+ describe('email preview end-to-end (examples/basic)', () => {
18
+ it('lists Welcome and inlines its emails/styles/email.css; client/* alias resolves', async () => {
19
+ const cfg = await loadConfig({ root: EXAMPLE });
20
+ const items = listEmails(cfg);
21
+ expect(items.map((i) => i.name)).toContain('Welcome');
22
+
23
+ const server = await createServer({
24
+ ...(await createViteConfig(cfg)),
25
+ server: { middlewareMode: true, hmr: false },
26
+ appType: 'custom',
27
+ logLevel: 'silent',
28
+ });
29
+ try {
30
+ const r = await renderEmailByName(server, cfg, 'Welcome');
31
+ if (!r) throw new Error('Welcome did not render');
32
+ // tokens discovered from props
33
+ expect(r.tokens).toEqual(['code', 'name']);
34
+ // subject token template
35
+ expect(r.subject).toBe('Welcome to toiljs, {{name}}');
36
+ // .email-title { color: #f5f6fa } from emails/styles/email.css inlined onto the <h1>
37
+ expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#f5f6fa/i);
38
+ // .email-card { background-color: #0e1520 } inlined onto the card <table>
39
+ expect(r.html).toMatch(/<table[^>]*style="[^"]*background-color:\s*#0e1520/i);
40
+
41
+ // The `client/*` reuse alias still resolves project CSS (the documented
42
+ // `import 'client/styles/…'` path), independent of where the demo keeps its styles.
43
+ const aliased = (await server.ssrLoadModule('client/styles/main.css?inline')) as {
44
+ default?: unknown;
45
+ };
46
+ expect(typeof aliased.default).toBe('string');
47
+ } finally {
48
+ await server.close();
49
+ }
50
+ }, 30000);
51
+
52
+ it('emailsVersion is a non-empty mtime:count fingerprint', async () => {
53
+ const cfg = await loadConfig({ root: EXAMPLE });
54
+ const v = emailsVersion(cfg);
55
+ expect(v).toMatch(/^\d+(\.\d+)?:\d+$/);
56
+ // at least Welcome.tsx + the client CSS files were counted
57
+ expect(Number(v.split(':')[1])).toBeGreaterThan(1);
58
+ });
59
+
60
+ it('the preview shell wires the dev endpoints', () => {
61
+ const html = previewShellHtml();
62
+ expect(html).toContain("var BASE = '/__toil/emails'");
63
+ for (const frag of ["BASE + '/list'", "BASE + '/render?name='", "BASE + '/version'"]) {
64
+ expect(html).toContain(frag);
65
+ }
66
+ expect(html).toContain('/__toil/open?file='); // open-in-editor
67
+ });
68
+ });
@@ -0,0 +1,58 @@
1
+ import { createElement, type ReactElement } from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { __test } from '../src/compiler/emails';
6
+
7
+ const render = (el: unknown): string => renderToStaticMarkup(el as ReactElement);
8
+
9
+ describe('renderModule', () => {
10
+ it('discovers props as {{tokens}} and renders placeholders (alpha-sorted, deduped)', async () => {
11
+ const mod = {
12
+ default: (p: { name: string; code: string }) =>
13
+ createElement('p', null, `Hi ${p.name}, code ${p.code}`),
14
+ };
15
+ const r = await __test.renderModule('Welcome', mod, render);
16
+ if (!r) throw new Error('expected a rendered email');
17
+ expect(r.tokens).toEqual(['code', 'name']);
18
+ expect(r.html).toContain('{{name}}');
19
+ expect(r.html).toContain('{{code}}');
20
+ });
21
+
22
+ it('returns null when there is no default-exported component', async () => {
23
+ expect(await __test.renderModule('X', { default: 'nope' }, render)).toBeNull();
24
+ });
25
+
26
+ it('inlines imported CSS into element style="" (the reuse path)', async () => {
27
+ const mod = { default: () => createElement('h1', { className: 'email-title' }, 'Hello') };
28
+ const css = '.email-title { color: #111827; font-size: 22px; }';
29
+ const r = await __test.renderModule('Styled', mod, render, css);
30
+ if (!r) throw new Error('expected a rendered email');
31
+ // The class rule is moved onto the element as an inline style by the inliner.
32
+ expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#111827/i);
33
+ expect(r.html).toMatch(/font-size:\s*22px/i);
34
+ });
35
+ });
36
+
37
+ describe('renderModuleSource', () => {
38
+ it('generates a typed Emails.<Name>.send with alpha-sorted token params', () => {
39
+ const src = __test.renderModuleSource([
40
+ {
41
+ name: 'Welcome',
42
+ subject: 'Welcome, {{name}}!',
43
+ html: '<p>{{code}}</p>',
44
+ text: 'code {{code}}',
45
+ tokens: ['code', 'name'],
46
+ purpose: 'welcome',
47
+ },
48
+ ]);
49
+ expect(src).toContain('export namespace Emails {');
50
+ expect(src).toContain('export namespace Welcome {');
51
+ expect(src).toContain(
52
+ 'export function send(to: string, code: string, name: string, purpose: string = "welcome")',
53
+ );
54
+ expect(src).toContain(
55
+ 'return new EmailTemplate(SUBJECT, TEXT, HTML).send(to, __v, purpose);',
56
+ );
57
+ });
58
+ });