react-email-rails 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/build.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createBuilder } from "vite"
3
- import { fail, isolatedViteConfig, loadReactEmailRailsConfig } from "./shared.mjs"
4
- import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
3
+ import {
4
+ exitIfHealthCheck,
5
+ fail,
6
+ isolatedViteConfig,
7
+ loadReactEmailRailsConfig,
8
+ } from "./shared.mjs"
5
9
 
6
- if (process.argv.includes("--health")) {
7
- process.stdout.write(
8
- JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
9
- )
10
- process.exit(0)
11
- }
10
+ exitIfHealthCheck()
12
11
 
13
12
  const args = process.argv.slice(2)
14
13
  const readOption = (long, short) => {
@@ -52,9 +51,8 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
52
51
  configLoader,
53
52
  })
54
53
 
55
- // Build production emails with the same isolation principle as the dev renderer:
56
- // keep component resolution and transforms, but leave unrelated app plugins out
57
- // of the email environment so client/global plugin hooks cannot break SSR output.
54
+ // Same isolation as the dev renderer: keep component resolve/transforms but leave unrelated
55
+ // app plugins out of the email environment so their hooks can't break SSR output.
58
56
  const builder = await createBuilder(
59
57
  isolatedViteConfig(userConfig, vite, {
60
58
  root: root ?? userConfig.root,
package/bin/dev.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer, isRunnableDevEnvironment } from "vite"
3
- import { fail, isolatedViteConfig, loadReactEmailRailsConfig } from "./shared.mjs"
4
- import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
3
+ import {
4
+ exitIfHealthCheck,
5
+ fail,
6
+ isolatedViteConfig,
7
+ loadReactEmailRailsConfig,
8
+ } from "./shared.mjs"
5
9
 
6
- if (process.argv.includes("--health")) {
7
- process.stdout.write(
8
- JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
9
- )
10
- process.exit(0)
11
- }
10
+ exitIfHealthCheck()
12
11
 
13
12
  const toStderr = (message) => process.stderr.write(`${message}\n`)
14
13
  const logger = {
@@ -39,9 +38,7 @@ const { userConfig, plugin, vite } = await loadReactEmailRailsConfig({
39
38
  mode: "development",
40
39
  })
41
40
 
42
- // Forward config that affects how components resolve and compile (but not the
43
- // host's dev-server plugins, which have global side effects), so dev rendering
44
- // stays close to the production email bundle.
41
+ // Forward only component resolve/compile config, so dev rendering stays close to the build.
45
42
  const server = await createServer(
46
43
  isolatedViteConfig(userConfig, vite, {
47
44
  configFile: false,
@@ -53,8 +50,7 @@ const server = await createServer(
53
50
  }),
54
51
  )
55
52
 
56
- // Render through the same `email` environment the production build uses, so dev
57
- // and build resolve and compile components identically.
53
+ // Render through the same `email` environment as the production build, so the two match.
58
54
  const environment = server.environments.email
59
55
  if (!isRunnableDevEnvironment(environment)) {
60
56
  await server.close()
@@ -63,6 +59,7 @@ if (!isRunnableDevEnvironment(environment)) {
63
59
 
64
60
  try {
65
61
  const { run } = await environment.runner.import("virtual:react-email-rails/server")
62
+ // Restore before run(): serve() re-isolates stdout with its own protocol writer.
66
63
  restoreStdout()
67
64
  await run()
68
65
  } finally {
package/bin/shared.mjs CHANGED
@@ -1,7 +1,11 @@
1
1
  import { loadConfigFromFile, mergeConfig } from "vite"
2
2
 
3
+ import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
4
+
5
+ // Wire contract: must match the Symbol.for(...) keys in src/index.ts.
3
6
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
4
7
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
8
+ // Must mirror ReactEmailRailsViteOptions in src/index.ts and the option list in README.md.
5
9
  const EMAIL_VITE_CONFIG_KEYS = [
6
10
  "assetsInclude",
7
11
  "css",
@@ -13,6 +17,16 @@ const EMAIL_VITE_CONFIG_KEYS = [
13
17
  "resolve",
14
18
  ]
15
19
 
20
+ // Emit the version/protocol handshake and exit on --health. Shared by the build and dev bins.
21
+ export function exitIfHealthCheck() {
22
+ if (!process.argv.includes("--health")) return
23
+
24
+ process.stdout.write(
25
+ JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }),
26
+ )
27
+ process.exit(0)
28
+ }
29
+
16
30
  export async function loadReactEmailRailsConfig({
17
31
  command,
18
32
  mode,
@@ -1,6 +1,6 @@
1
1
  import { type Extensions } from "@tiptap/core";
2
- import type { DroppedNode, RenderResult } from "./runtime.js";
3
- export type { DroppedNode };
2
+ import type { DroppedNode, ParseDocumentRequest, ParseResult, RenderDocumentRequest, RenderResult } from "./runtime.js";
3
+ export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest };
4
4
  export type DocumentRenderer = {
5
5
  buildExtensions: (context: unknown) => Extensions;
6
6
  transformDocument?: (document: unknown, context: unknown) => unknown;
@@ -8,11 +8,12 @@ export type DocumentRenderer = {
8
8
  };
9
9
  export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>);
10
10
  export type DocumentRegistry = Record<string, DocumentLoader>;
11
- export type RenderDocumentRequest = {
12
- kind: "document";
13
- type: string;
14
- document: unknown;
15
- context?: unknown;
16
- preview?: string | null;
11
+ type GenerateJSON = (html: string, extensions: Extensions) => unknown;
12
+ type RenderMarkdown = (markdown: string) => string | Promise<string>;
13
+ type ParseDependencies = {
14
+ generateJSON?: GenerateJSON;
15
+ renderMarkdown?: RenderMarkdown;
17
16
  };
18
17
  export declare function composeDocument(request: RenderDocumentRequest, registry: DocumentRegistry): Promise<RenderResult>;
18
+ export declare function parseDocument(request: ParseDocumentRequest, registry: DocumentRegistry, dependencies?: ParseDependencies): Promise<ParseResult>;
19
+ export declare function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown): (request: ParseDocumentRequest, registry: DocumentRegistry) => Promise<ParseResult>;
package/dist/document.js CHANGED
@@ -7,9 +7,8 @@ import { getSchema, resolveExtensions } from "@tiptap/core";
7
7
  const STRUCTURAL_NODE_TYPES = new Set(resolveExtensions([StarterKit, EmailTheming])
8
8
  .filter((extension) => extension.type === "node" && !(extension instanceof EmailNode))
9
9
  .map((extension) => extension.name));
10
- // composeReactEmail renders a node as null when no extension matches its type or
11
- // the match is not an EmailNode. Mirror that predicate over the document so
12
- // warnings report the silent case: an in-schema node with no email renderer.
10
+ // composeReactEmail renders a node as null when no extension matches or the match isn't an
11
+ // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
13
12
  function collectDroppedNodes(document, extensions) {
14
13
  const byName = new Map();
15
14
  for (const extension of extensions)
@@ -35,6 +34,54 @@ function collectDroppedNodes(document, extensions) {
35
34
  walk(document.content);
36
35
  return [...counts].map(([type, count]) => ({ type, count }));
37
36
  }
37
+ async function resolveRenderer(type, registry) {
38
+ const loader = registry[type];
39
+ if (!loader)
40
+ throw new Error(`React email document renderer not found: ${type}`);
41
+ const renderer = typeof loader === "function" ? await loader() : loader;
42
+ if (typeof renderer.buildExtensions !== "function") {
43
+ throw new Error(`React email document renderer must export a buildExtensions function: ${type}`);
44
+ }
45
+ return renderer;
46
+ }
47
+ async function loadGenerateJSON() {
48
+ try {
49
+ const mod = (await import(/* @vite-ignore */ "@tiptap/html"));
50
+ if (typeof mod.generateJSON === "function")
51
+ return mod.generateJSON;
52
+ }
53
+ catch (error) {
54
+ throw new Error(`@tiptap/html and happy-dom are required to parse HTML documents; install both packages before calling parse (${error instanceof Error ? error.message : "module load failed"})`);
55
+ }
56
+ throw new Error("@tiptap/html is missing the expected generateJSON export; check the installed version");
57
+ }
58
+ async function loadRenderMarkdown() {
59
+ try {
60
+ const mod = (await import(/* @vite-ignore */ "marked"));
61
+ const marked = mod.marked;
62
+ if (marked && typeof marked.parse === "function") {
63
+ const parse = marked.parse.bind(marked);
64
+ return (markdown) => parse(markdown);
65
+ }
66
+ }
67
+ catch (error) {
68
+ throw new Error(`marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`);
69
+ }
70
+ throw new Error("marked is missing the expected parse export; check the installed version");
71
+ }
72
+ // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
73
+ async function resolveHtmlInput(request, dependencies) {
74
+ const hasHtml = request.html !== undefined;
75
+ const hasMarkdown = request.markdown !== undefined;
76
+ if (hasHtml === hasMarkdown) {
77
+ throw new Error("parse request must include exactly one of `html` or `markdown`");
78
+ }
79
+ if (hasMarkdown) {
80
+ const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown());
81
+ return renderMarkdown(request.markdown);
82
+ }
83
+ return request.html;
84
+ }
38
85
  export async function composeDocument(request, registry) {
39
86
  // Fail legibly if the optional editor peers are present but their shape shifted.
40
87
  if (typeof composeReactEmail !== "function" ||
@@ -42,13 +89,7 @@ export async function composeDocument(request, registry) {
42
89
  typeof getSchema !== "function") {
43
90
  throw new Error("@react-email/editor or @tiptap/core is missing expected exports (composeReactEmail/resolveExtensions/getSchema); check the installed versions");
44
91
  }
45
- const loader = registry[request.type];
46
- if (!loader)
47
- throw new Error(`React email document renderer not found: ${request.type}`);
48
- const renderer = typeof loader === "function" ? await loader() : loader;
49
- if (typeof renderer.buildExtensions !== "function") {
50
- throw new Error(`React email document renderer must export a buildExtensions function: ${request.type}`);
51
- }
92
+ const renderer = await resolveRenderer(request.type, registry);
52
93
  const document = renderer.transformDocument?.(request.document, request.context) ?? request.document;
53
94
  const extensions = resolveExtensions(renderer.buildExtensions(request.context));
54
95
  const schema = getSchema(extensions);
@@ -67,3 +108,18 @@ export async function composeDocument(request, registry) {
67
108
  const { html, text } = await composeReactEmail(params);
68
109
  return warnings.length > 0 ? { html, text, warnings } : { html, text };
69
110
  }
111
+ export async function parseDocument(request, registry, dependencies = {}) {
112
+ const renderer = await resolveRenderer(request.type, registry);
113
+ const extensions = resolveExtensions(renderer.buildExtensions(request.context));
114
+ const schema = getSchema(extensions);
115
+ const html = await resolveHtmlInput(request, dependencies);
116
+ const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON());
117
+ const parsed = parseHTML(html, extensions);
118
+ const document = schema.nodeFromJSON(parsed).toJSON();
119
+ return { document };
120
+ }
121
+ export function createParseDocument(generateJSON, renderMarkdown) {
122
+ // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
123
+ const dependencies = renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown };
124
+ return (request, registry) => parseDocument(request, registry, dependencies);
125
+ }
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
2
3
  const DEFAULT_EMAIL_PATH = "app/javascript/emails";
3
4
  const DEFAULT_EMAIL_EXTENSIONS = [".tsx", ".jsx"];
@@ -10,10 +11,20 @@ const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
10
11
  const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/;
11
12
  // The dedicated build environment that emits the server-side email bundle.
12
13
  export const EMAIL_ENVIRONMENT = "email";
14
+ // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
13
15
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
14
16
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite");
17
+ // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
15
18
  const OUT_DIR = "tmp/react-email-rails";
16
19
  const BUNDLE_FILE = "emails.js";
20
+ const require = createRequire(import.meta.url);
21
+ // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
22
+ // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
23
+ // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
24
+ const WS_NATIVE_ADDON_OPT_OUT = {
25
+ "process.env.WS_NO_BUFFER_UTIL": "'1'",
26
+ "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
27
+ };
17
28
  function normalizeSource(option, defaultPath, defaultExtensions) {
18
29
  const source = typeof option === "string" ? { path: option } : (option ?? {});
19
30
  const path = (source.path ?? defaultPath).replace(/^\/|\/$/g, "");
@@ -38,6 +49,17 @@ function normalizeSource(option, defaultPath, defaultExtensions) {
38
49
  const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
39
50
  return { path, extensions, ignore, root, globArg };
40
51
  }
52
+ function optionalPeersAvailable(specifiers) {
53
+ return specifiers.every((specifier) => {
54
+ try {
55
+ require.resolve(specifier);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ }
41
63
  export function reactEmailRails(options = {}) {
42
64
  const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS);
43
65
  const documentSource = options.documents === undefined || options.documents === false
@@ -59,13 +81,23 @@ export function reactEmailRails(options = {}) {
59
81
  filter: { id: VIRTUAL_MODULE_PATTERN },
60
82
  handler(id) {
61
83
  if (id === RESOLVED_SERVER) {
62
- const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`];
63
- // Imported only here, so the editor stays out of the email build graph when off.
64
- if (documentSource)
65
- lines.push(`import { composeDocument } from "react-email-rails/document"`);
66
- lines.push(`const modules = import.meta.glob(${emailSource.globArg})`, `const extensions = ${JSON.stringify(emailSource.extensions)}`, `const registry = Object.create(null)`, `for (const path in modules) {`, ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`, `}`);
84
+ const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`];
85
+ const parserPeersAvailable = documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"]);
86
+ // Markdown parsing layers `marked` on top of the HTML parser peers.
87
+ const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"]);
88
+ if (documentSource) {
89
+ lines.push(parserPeersAvailable
90
+ ? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
91
+ : `import { composeDocument, parseDocument } from "react-email-rails/document"`);
92
+ if (parserPeersAvailable) {
93
+ lines.push(`import { generateJSON } from "@tiptap/html"`);
94
+ if (markdownPeerAvailable)
95
+ lines.push(`import { marked } from "marked"`);
96
+ }
97
+ }
98
+ lines.push(`const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`);
67
99
  if (documentSource) {
68
- lines.push(`const documentModules = import.meta.glob(${documentSource.globArg})`, `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`, `const documentRegistry = Object.create(null)`, `for (const path in documentModules) {`, ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`, ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`, `}`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument })`);
100
+ lines.push(`const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`, `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`);
69
101
  }
70
102
  else {
71
103
  lines.push(`export const run = () => serve(registry)`);
@@ -78,17 +110,15 @@ export function reactEmailRails(options = {}) {
78
110
  },
79
111
  },
80
112
  config(_config, env) {
81
- // Register a dedicated `email` build environment. The official
82
- // react-email-rails-build bin opts into building it with an isolated
83
- // plugin stack so host app plugins cannot break email SSR builds.
84
- // The environment is a server consumer. Production standalone builds inline
85
- // Node dependencies by default so Rails runtime images do not need
86
- // node_modules; dev rendering keeps dependencies external for Vite's module
87
- // runner.
113
+ // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
114
+ // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
115
+ // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
88
116
  return {
89
117
  environments: {
90
118
  [EMAIL_ENVIRONMENT]: {
91
- ...(standalone && env.command === "build" ? { resolve: { noExternal: true } } : {}),
119
+ ...(standalone && env.command === "build"
120
+ ? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
121
+ : {}),
92
122
  build: {
93
123
  ssr: true,
94
124
  outDir: OUT_DIR,
package/dist/runtime.d.ts CHANGED
@@ -24,6 +24,13 @@ export type RenderDocumentRequest = {
24
24
  context?: unknown;
25
25
  preview?: string | null;
26
26
  };
27
+ export type ParseDocumentRequest = {
28
+ kind: "parse";
29
+ type: string;
30
+ html?: string;
31
+ markdown?: string;
32
+ context?: unknown;
33
+ };
27
34
  export type DroppedNode = {
28
35
  type: string;
29
36
  count: number;
@@ -31,14 +38,19 @@ export type DroppedNode = {
31
38
  export type RenderResult = RenderedEmail & {
32
39
  warnings?: DroppedNode[];
33
40
  };
41
+ export type ParseResult = {
42
+ document: unknown;
43
+ };
34
44
  export type DocumentSupport<Registry = unknown> = {
35
45
  registry: Registry;
36
46
  compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>;
47
+ parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>;
37
48
  };
38
49
  export type EmailRenderOptions = {
39
50
  html?: ReactEmailRenderOptions;
40
51
  text?: ReactEmailRenderOptions;
41
52
  };
42
53
  export declare function toComponentName(globPath: string, root: string, extension: string): string;
54
+ export declare function buildRegistry(modules: EmailRegistry, extensions: string[], root: string): EmailRegistry;
43
55
  export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
44
56
  export declare function serve<Registry = unknown>(registry: EmailRegistry, documents?: DocumentSupport<Registry> | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -5,6 +5,15 @@ export function toComponentName(globPath, root, extension) {
5
5
  const start = globPath.lastIndexOf(root) + root.length;
6
6
  return globPath.slice(start, globPath.length - extension.length);
7
7
  }
8
+ // Map glob results to a component-name registry (used for both email and document registries).
9
+ export function buildRegistry(modules, extensions, root) {
10
+ const registry = Object.create(null);
11
+ for (const [path, loader] of Object.entries(modules)) {
12
+ const extension = extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."));
13
+ registry[toComponentName(path, root, extension)] = loader;
14
+ }
15
+ return registry;
16
+ }
8
17
  export async function renderEmail(request, registry) {
9
18
  const loader = registry[request.component];
10
19
  if (!loader)
@@ -20,18 +29,26 @@ export async function renderEmail(request, registry) {
20
29
  function isDocumentRequest(request) {
21
30
  return (request !== null &&
22
31
  typeof request === "object" &&
32
+ "kind" in request &&
23
33
  request.kind === "document");
24
34
  }
35
+ function isParseRequest(request) {
36
+ return (request !== null && typeof request === "object" && "kind" in request && request.kind === "parse");
37
+ }
25
38
  function isHealthRequest(request) {
26
39
  return request !== null && typeof request === "object" && "health" in request;
27
40
  }
28
- // Requests without a document kind are component renders, preserving the email path.
29
41
  async function renderRequest(request, registry, documents) {
30
42
  if (isDocumentRequest(request)) {
31
43
  if (!documents)
32
44
  throw new Error("React email document rendering is not enabled");
33
45
  return documents.compose(request, documents.registry);
34
46
  }
47
+ if (isParseRequest(request)) {
48
+ if (!documents)
49
+ throw new Error("React email document rendering is not enabled");
50
+ return documents.parse(request, documents.registry);
51
+ }
35
52
  return renderEmail(request, registry);
36
53
  }
37
54
  export async function serve(registry, documents = null) {
@@ -56,13 +73,15 @@ export async function serve(registry, documents = null) {
56
73
  process.exitCode = 1;
57
74
  }
58
75
  }
59
- // Reserve stdout for the JSON render protocol. Stray writes from email components
60
- // or their dependencies including console.log, which Node routes through
61
- // process.stdout.write are diverted to stderr so they cannot corrupt or desync
62
- // a response frame. Returns the writer to use for protocol output.
76
+ // Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
77
+ // routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
78
+ // Returns the writer to use for protocol output.
63
79
  function isolateStdout() {
64
80
  const protocolWrite = process.stdout.write.bind(process.stdout);
65
- process.stdout.write = ((chunk) => process.stderr.write(chunk));
81
+ // Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
82
+ process.stdout.write = ((chunk, encoding, callback) => typeof encoding === "function"
83
+ ? process.stderr.write(chunk, encoding)
84
+ : process.stderr.write(chunk, encoding, callback));
66
85
  return (chunk) => protocolWrite(chunk);
67
86
  }
68
87
  function readStdin() {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.2.0";
2
- export declare const RENDER_PROTOCOL_VERSION = 2;
1
+ export declare const VERSION = "0.4.0";
2
+ export declare const RENDER_PROTOCOL_VERSION = 3;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.2.0";
2
- export const RENDER_PROTOCOL_VERSION = 2;
1
+ export const VERSION = "0.4.0";
2
+ export const RENDER_PROTOCOL_VERSION = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email-rails",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build and send emails using React and Rails",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,6 +64,9 @@
64
64
  "@react-email/editor": "^1.5",
65
65
  "@react-email/render": "^2.0.0",
66
66
  "@tiptap/core": "^3",
67
+ "@tiptap/html": "^3",
68
+ "happy-dom": "^20.8.9",
69
+ "marked": "^18",
67
70
  "react": "^18.0 || ^19.0",
68
71
  "vite": "^7.0.0 || ^8.0.0"
69
72
  },
@@ -73,15 +76,28 @@
73
76
  },
74
77
  "@tiptap/core": {
75
78
  "optional": true
79
+ },
80
+ "@tiptap/html": {
81
+ "optional": true
82
+ },
83
+ "happy-dom": {
84
+ "optional": true
85
+ },
86
+ "marked": {
87
+ "optional": true
76
88
  }
77
89
  },
78
90
  "devDependencies": {
79
91
  "@react-email/editor": "^1.5.3",
80
92
  "@react-email/render": "^2.0.8",
81
93
  "@tiptap/core": "^3",
94
+ "@tiptap/html": "^3",
95
+ "@tiptap/pm": "^3",
82
96
  "@types/node": "^25.9.1",
83
97
  "@types/react": "^19.2.15",
84
98
  "@typescript/native-preview": "7.0.0-dev.20260527.2",
99
+ "happy-dom": "^20.8.9",
100
+ "marked": "^18.0.5",
85
101
  "oxfmt": "^0.52.0",
86
102
  "oxlint": "^1.67.0",
87
103
  "oxlint-tsgolint": "^0.23.0",
package/src/document.ts CHANGED
@@ -4,9 +4,16 @@ import { EmailTheming } from "@react-email/editor/plugins"
4
4
  import { getSchema, resolveExtensions, type Extensions } from "@tiptap/core"
5
5
  import type { Editor } from "@tiptap/core"
6
6
 
7
- import type { DroppedNode, RenderResult } from "./runtime.js"
7
+ import type {
8
+ DroppedNode,
9
+ ParseDocumentRequest,
10
+ ParseResult,
11
+ RenderDocumentRequest,
12
+ RenderResult,
13
+ } from "./runtime.js"
8
14
 
9
- export type { DroppedNode }
15
+ // Re-exported from runtime.ts (the single source) to keep react-email-rails/document's surface.
16
+ export type { DroppedNode, ParseDocumentRequest, RenderDocumentRequest }
10
17
 
11
18
  export type DocumentRenderer = {
12
19
  buildExtensions: (context: unknown) => Extensions
@@ -22,9 +29,8 @@ const STRUCTURAL_NODE_TYPES: ReadonlySet<string> = new Set(
22
29
  .map((extension) => extension.name),
23
30
  )
24
31
 
25
- // composeReactEmail renders a node as null when no extension matches its type or
26
- // the match is not an EmailNode. Mirror that predicate over the document so
27
- // warnings report the silent case: an in-schema node with no email renderer.
32
+ // composeReactEmail renders a node as null when no extension matches or the match isn't an
33
+ // EmailNode; mirror that here so warnings catch the silent case (in-schema node, no email renderer).
28
34
  function collectDroppedNodes(document: unknown, extensions: Extensions): DroppedNode[] {
29
35
  const byName = new Map<string, Extensions[number]>()
30
36
  for (const extension of extensions) byName.set(extension.name, extension)
@@ -53,12 +59,83 @@ function collectDroppedNodes(document: unknown, extensions: Extensions): Dropped
53
59
  export type DocumentLoader = DocumentRenderer | (() => Promise<DocumentRenderer>)
54
60
  export type DocumentRegistry = Record<string, DocumentLoader>
55
61
 
56
- export type RenderDocumentRequest = {
57
- kind: "document"
58
- type: string
59
- document: unknown
60
- context?: unknown
61
- preview?: string | null
62
+ type GenerateJSON = (html: string, extensions: Extensions) => unknown
63
+ type RenderMarkdown = (markdown: string) => string | Promise<string>
64
+
65
+ // Bound at build time by createParseDocument when the peers are bundled; lazy-loaded otherwise.
66
+ type ParseDependencies = {
67
+ generateJSON?: GenerateJSON
68
+ renderMarkdown?: RenderMarkdown
69
+ }
70
+
71
+ async function resolveRenderer(
72
+ type: string,
73
+ registry: DocumentRegistry,
74
+ ): Promise<DocumentRenderer> {
75
+ const loader = registry[type]
76
+ if (!loader) throw new Error(`React email document renderer not found: ${type}`)
77
+
78
+ const renderer = typeof loader === "function" ? await loader() : loader
79
+ if (typeof renderer.buildExtensions !== "function") {
80
+ throw new Error(`React email document renderer must export a buildExtensions function: ${type}`)
81
+ }
82
+
83
+ return renderer
84
+ }
85
+
86
+ async function loadGenerateJSON(): Promise<GenerateJSON> {
87
+ try {
88
+ const mod = (await import(/* @vite-ignore */ "@tiptap/html")) as {
89
+ generateJSON?: unknown
90
+ }
91
+ if (typeof mod.generateJSON === "function") return mod.generateJSON as GenerateJSON
92
+ } catch (error) {
93
+ throw new Error(
94
+ `@tiptap/html and happy-dom are required to parse HTML documents; install both packages before calling parse (${error instanceof Error ? error.message : "module load failed"})`,
95
+ )
96
+ }
97
+
98
+ throw new Error(
99
+ "@tiptap/html is missing the expected generateJSON export; check the installed version",
100
+ )
101
+ }
102
+
103
+ async function loadRenderMarkdown(): Promise<RenderMarkdown> {
104
+ try {
105
+ const mod = (await import(/* @vite-ignore */ "marked")) as {
106
+ marked?: { parse?: (markdown: string) => string | Promise<string> }
107
+ }
108
+ const marked = mod.marked
109
+ if (marked && typeof marked.parse === "function") {
110
+ const parse = marked.parse.bind(marked)
111
+ return (markdown) => parse(markdown)
112
+ }
113
+ } catch (error) {
114
+ throw new Error(
115
+ `marked is required to parse Markdown documents; install it before calling parse with markdown (${error instanceof Error ? error.message : "module load failed"})`,
116
+ )
117
+ }
118
+
119
+ throw new Error("marked is missing the expected parse export; check the installed version")
120
+ }
121
+
122
+ // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
123
+ async function resolveHtmlInput(
124
+ request: ParseDocumentRequest,
125
+ dependencies: ParseDependencies,
126
+ ): Promise<string> {
127
+ const hasHtml = request.html !== undefined
128
+ const hasMarkdown = request.markdown !== undefined
129
+ if (hasHtml === hasMarkdown) {
130
+ throw new Error("parse request must include exactly one of `html` or `markdown`")
131
+ }
132
+
133
+ if (hasMarkdown) {
134
+ const renderMarkdown = dependencies.renderMarkdown ?? (await loadRenderMarkdown())
135
+ return renderMarkdown(request.markdown as string)
136
+ }
137
+
138
+ return request.html as string
62
139
  }
63
140
 
64
141
  export async function composeDocument(
@@ -76,15 +153,7 @@ export async function composeDocument(
76
153
  )
77
154
  }
78
155
 
79
- const loader = registry[request.type]
80
- if (!loader) throw new Error(`React email document renderer not found: ${request.type}`)
81
-
82
- const renderer = typeof loader === "function" ? await loader() : loader
83
- if (typeof renderer.buildExtensions !== "function") {
84
- throw new Error(
85
- `React email document renderer must export a buildExtensions function: ${request.type}`,
86
- )
87
- }
156
+ const renderer = await resolveRenderer(request.type, registry)
88
157
 
89
158
  const document =
90
159
  renderer.transformDocument?.(request.document, request.context) ?? request.document
@@ -108,3 +177,28 @@ export async function composeDocument(
108
177
  const { html, text } = await composeReactEmail(params)
109
178
  return warnings.length > 0 ? { html, text, warnings } : { html, text }
110
179
  }
180
+
181
+ export async function parseDocument(
182
+ request: ParseDocumentRequest,
183
+ registry: DocumentRegistry,
184
+ dependencies: ParseDependencies = {},
185
+ ): Promise<ParseResult> {
186
+ const renderer = await resolveRenderer(request.type, registry)
187
+ const extensions = resolveExtensions(renderer.buildExtensions(request.context))
188
+ const schema = getSchema(extensions)
189
+
190
+ const html = await resolveHtmlInput(request, dependencies)
191
+ const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON())
192
+ const parsed = parseHTML(html, extensions)
193
+ const document = schema.nodeFromJSON(parsed).toJSON()
194
+
195
+ return { document }
196
+ }
197
+
198
+ export function createParseDocument(generateJSON: GenerateJSON, renderMarkdown?: RenderMarkdown) {
199
+ // Omit renderMarkdown entirely when unset; exactOptionalPropertyTypes forbids passing `undefined`.
200
+ const dependencies: ParseDependencies =
201
+ renderMarkdown === undefined ? { generateJSON } : { generateJSON, renderMarkdown }
202
+ return (request: ParseDocumentRequest, registry: DocumentRegistry): Promise<ParseResult> =>
203
+ parseDocument(request, registry, dependencies)
204
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createRequire } from "node:module"
2
+
1
3
  import type { ConfigEnv, Plugin, UserConfig } from "vite"
2
4
 
3
5
  export type EmailsOption =
@@ -10,8 +12,7 @@ export type EmailsOption =
10
12
 
11
13
  export type ReactEmailRailsOptions = {
12
14
  emails?: EmailsOption
13
- // Editor document renderers, discovered like emails. Off by default; pass `true`
14
- // to enable with defaults, or a path/options object to customize discovery.
15
+ // Editor document renderers, discovered like emails. Off by default.
15
16
  documents?: EmailsOption | boolean
16
17
  standalone?: boolean
17
18
  vite?: ReactEmailRailsViteOptions
@@ -60,10 +61,21 @@ const VIRTUAL_MODULE_PATTERN = /virtual:react-email-rails\/(?:server|main)$/
60
61
 
61
62
  // The dedicated build environment that emits the server-side email bundle.
62
63
  export const EMAIL_ENVIRONMENT = "email"
64
+ // Wire contract: must match the Symbol.for(...) keys the bins read in bin/shared.mjs.
63
65
  const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
64
66
  const VITE_CONFIG_SYMBOL = Symbol.for("react-email-rails.vite")
67
+ // Must match Ruby's Configuration::BUNDLE_PATH (check_version_sync.rb asserts it).
65
68
  const OUT_DIR = "tmp/react-email-rails"
66
69
  const BUNDLE_FILE = "emails.js"
70
+ const require = createRequire(import.meta.url)
71
+
72
+ // happy-dom (via @tiptap/html) pulls in `ws`, which guards optional native-addon requires behind
73
+ // these flags. Setting them lets a standalone (noExternal) build tree-shake the uninstalled
74
+ // bufferutil/utf-8-validate requires away; ws's pure-JS path is all the HTML parser needs.
75
+ const WS_NATIVE_ADDON_OPT_OUT = {
76
+ "process.env.WS_NO_BUFFER_UTIL": "'1'",
77
+ "process.env.WS_NO_UTF_8_VALIDATE": "'1'",
78
+ }
67
79
 
68
80
  function normalizeSource(
69
81
  option: EmailsOption | undefined,
@@ -101,6 +113,17 @@ function normalizeSource(
101
113
  return { path, extensions, ignore, root, globArg }
102
114
  }
103
115
 
116
+ function optionalPeersAvailable(specifiers: string[]): boolean {
117
+ return specifiers.every((specifier) => {
118
+ try {
119
+ require.resolve(specifier)
120
+ return true
121
+ } catch {
122
+ return false
123
+ }
124
+ })
125
+ }
126
+
104
127
  export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
105
128
  const emailSource = normalizeSource(options.emails, DEFAULT_EMAIL_PATH, DEFAULT_EMAIL_EXTENSIONS)
106
129
  const documentSource =
@@ -128,32 +151,32 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
128
151
  filter: { id: VIRTUAL_MODULE_PATTERN },
129
152
  handler(id) {
130
153
  if (id === RESOLVED_SERVER) {
131
- const lines = [`import { serve, toComponentName } from "react-email-rails/runtime"`]
154
+ const lines = [`import { buildRegistry, serve } from "react-email-rails/runtime"`]
155
+ const parserPeersAvailable =
156
+ documentSource && optionalPeersAvailable(["@tiptap/html", "happy-dom"])
157
+ // Markdown parsing layers `marked` on top of the HTML parser peers.
158
+ const markdownPeerAvailable = parserPeersAvailable && optionalPeersAvailable(["marked"])
132
159
 
133
- // Imported only here, so the editor stays out of the email build graph when off.
134
- if (documentSource)
135
- lines.push(`import { composeDocument } from "react-email-rails/document"`)
160
+ if (documentSource) {
161
+ lines.push(
162
+ parserPeersAvailable
163
+ ? `import { composeDocument, createParseDocument } from "react-email-rails/document"`
164
+ : `import { composeDocument, parseDocument } from "react-email-rails/document"`,
165
+ )
166
+ if (parserPeersAvailable) {
167
+ lines.push(`import { generateJSON } from "@tiptap/html"`)
168
+ if (markdownPeerAvailable) lines.push(`import { marked } from "marked"`)
169
+ }
170
+ }
136
171
 
137
172
  lines.push(
138
- `const modules = import.meta.glob(${emailSource.globArg})`,
139
- `const extensions = ${JSON.stringify(emailSource.extensions)}`,
140
- `const registry = Object.create(null)`,
141
- `for (const path in modules) {`,
142
- ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
143
- ` registry[toComponentName(path, ${JSON.stringify(emailSource.root)}, extension)] = modules[path]`,
144
- `}`,
173
+ `const registry = buildRegistry(import.meta.glob(${emailSource.globArg}), ${JSON.stringify(emailSource.extensions)}, ${JSON.stringify(emailSource.root)})`,
145
174
  )
146
175
 
147
176
  if (documentSource) {
148
177
  lines.push(
149
- `const documentModules = import.meta.glob(${documentSource.globArg})`,
150
- `const documentExtensions = ${JSON.stringify(documentSource.extensions)}`,
151
- `const documentRegistry = Object.create(null)`,
152
- `for (const path in documentModules) {`,
153
- ` const extension = documentExtensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
154
- ` documentRegistry[toComponentName(path, ${JSON.stringify(documentSource.root)}, extension)] = documentModules[path]`,
155
- `}`,
156
- `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument })`,
178
+ `const documentRegistry = buildRegistry(import.meta.glob(${documentSource.globArg}), ${JSON.stringify(documentSource.extensions)}, ${JSON.stringify(documentSource.root)})`,
179
+ `export const run = () => serve(registry, { registry: documentRegistry, compose: composeDocument, parse: ${parserPeersAvailable ? `createParseDocument(generateJSON${markdownPeerAvailable ? ", (markdown) => marked.parse(markdown)" : ""})` : "parseDocument"} })`,
157
180
  )
158
181
  } else {
159
182
  lines.push(`export const run = () => serve(registry)`)
@@ -169,17 +192,15 @@ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
169
192
  },
170
193
 
171
194
  config(_config, env: ConfigEnv) {
172
- // Register a dedicated `email` build environment. The official
173
- // react-email-rails-build bin opts into building it with an isolated
174
- // plugin stack so host app plugins cannot break email SSR builds.
175
- // The environment is a server consumer. Production standalone builds inline
176
- // Node dependencies by default so Rails runtime images do not need
177
- // node_modules; dev rendering keeps dependencies external for Vite's module
178
- // runner.
195
+ // Dedicated `email` build environment: the react-email-rails-build bin builds it with an
196
+ // isolated plugin stack so host plugins can't break email SSR. Standalone builds inline
197
+ // Node deps (so Rails images need no node_modules); dev keeps them external for the runner.
179
198
  return {
180
199
  environments: {
181
200
  [EMAIL_ENVIRONMENT]: {
182
- ...(standalone && env.command === "build" ? { resolve: { noExternal: true } } : {}),
201
+ ...(standalone && env.command === "build"
202
+ ? { resolve: { noExternal: true }, define: WS_NATIVE_ADDON_OPT_OUT }
203
+ : {}),
183
204
  build: {
184
205
  ssr: true,
185
206
  outDir: OUT_DIR,
package/src/runtime.ts CHANGED
@@ -33,18 +33,28 @@ export type RenderDocumentRequest = {
33
33
  preview?: string | null
34
34
  }
35
35
 
36
+ // Exactly one of `html` or `markdown` is set.
37
+ export type ParseDocumentRequest = {
38
+ kind: "parse"
39
+ type: string
40
+ html?: string
41
+ markdown?: string
42
+ context?: unknown
43
+ }
44
+
36
45
  // A document node type that rendered to nothing, with how many times it occurred.
37
46
  export type DroppedNode = { type: string; count: number }
38
47
 
39
- // A render result plus any non-fatal warnings (document nodes dropped because no
40
- // extension rendered them). Component renders never carry warnings.
48
+ // A render result plus non-fatal warnings (dropped document nodes); component renders carry none.
41
49
  export type RenderResult = RenderedEmail & { warnings?: DroppedNode[] }
42
50
 
43
- // Injected by the generated server module when documents are enabled, so `serve`
44
- // renders documents without importing the editor module or its peer types.
51
+ export type ParseResult = { document: unknown }
52
+
53
+ // Injected by the generated server module so `serve` renders documents without importing the editor module.
45
54
  export type DocumentSupport<Registry = unknown> = {
46
55
  registry: Registry
47
56
  compose: (request: RenderDocumentRequest, registry: Registry) => Promise<RenderResult>
57
+ parse: (request: ParseDocumentRequest, registry: Registry) => Promise<ParseResult>
48
58
  }
49
59
 
50
60
  type ProtocolMetadata = {
@@ -62,6 +72,21 @@ export function toComponentName(globPath: string, root: string, extension: strin
62
72
  return globPath.slice(start, globPath.length - extension.length)
63
73
  }
64
74
 
75
+ // Map glob results to a component-name registry (used for both email and document registries).
76
+ export function buildRegistry(
77
+ modules: EmailRegistry,
78
+ extensions: string[],
79
+ root: string,
80
+ ): EmailRegistry {
81
+ const registry: EmailRegistry = Object.create(null)
82
+ for (const [path, loader] of Object.entries(modules)) {
83
+ const extension =
84
+ extensions.find((ext) => path.endsWith(ext)) ?? path.slice(path.lastIndexOf("."))
85
+ registry[toComponentName(path, root, extension)] = loader
86
+ }
87
+ return registry
88
+ }
89
+
65
90
  export async function renderEmail(
66
91
  request: RenderRequest,
67
92
  registry: EmailRegistry,
@@ -83,7 +108,14 @@ function isDocumentRequest(request: unknown): request is RenderDocumentRequest {
83
108
  return (
84
109
  request !== null &&
85
110
  typeof request === "object" &&
86
- (request as { kind?: unknown }).kind === "document"
111
+ "kind" in request &&
112
+ request.kind === "document"
113
+ )
114
+ }
115
+
116
+ function isParseRequest(request: unknown): request is ParseDocumentRequest {
117
+ return (
118
+ request !== null && typeof request === "object" && "kind" in request && request.kind === "parse"
87
119
  )
88
120
  }
89
121
 
@@ -91,17 +123,21 @@ function isHealthRequest(request: unknown): request is HealthRequest {
91
123
  return request !== null && typeof request === "object" && "health" in request
92
124
  }
93
125
 
94
- // Requests without a document kind are component renders, preserving the email path.
95
126
  async function renderRequest<Registry>(
96
- request: RenderRequest | RenderDocumentRequest,
127
+ request: RenderRequest | RenderDocumentRequest | ParseDocumentRequest,
97
128
  registry: EmailRegistry,
98
129
  documents: DocumentSupport<Registry> | null,
99
- ): Promise<RenderResult> {
130
+ ): Promise<RenderResult | ParseResult> {
100
131
  if (isDocumentRequest(request)) {
101
132
  if (!documents) throw new Error("React email document rendering is not enabled")
102
133
  return documents.compose(request, documents.registry)
103
134
  }
104
135
 
136
+ if (isParseRequest(request)) {
137
+ if (!documents) throw new Error("React email document rendering is not enabled")
138
+ return documents.parse(request, documents.registry)
139
+ }
140
+
105
141
  return renderEmail(request, registry)
106
142
  }
107
143
 
@@ -121,7 +157,10 @@ export async function serve<Registry = unknown>(
121
157
 
122
158
  const write = isolateStdout()
123
159
  try {
124
- const request = JSON.parse(await readStdin()) as RenderRequest | RenderDocumentRequest
160
+ const request = JSON.parse(await readStdin()) as
161
+ | RenderRequest
162
+ | RenderDocumentRequest
163
+ | ParseDocumentRequest
125
164
  write(
126
165
  JSON.stringify({
127
166
  ...(await renderRequest(request, registry, documents)),
@@ -134,14 +173,16 @@ export async function serve<Registry = unknown>(
134
173
  }
135
174
  }
136
175
 
137
- // Reserve stdout for the JSON render protocol. Stray writes from email components
138
- // or their dependencies including console.log, which Node routes through
139
- // process.stdout.write are diverted to stderr so they cannot corrupt or desync
140
- // a response frame. Returns the writer to use for protocol output.
176
+ // Reserve stdout for the JSON render protocol: stray writes (e.g. console.log, which Node
177
+ // routes through process.stdout.write) are diverted to stderr so they can't corrupt a frame.
178
+ // Returns the writer to use for protocol output.
141
179
  function isolateStdout(): (chunk: string) => boolean {
142
180
  const protocolWrite = process.stdout.write.bind(process.stdout)
143
- process.stdout.write = ((chunk: string | Uint8Array): boolean =>
144
- process.stderr.write(chunk)) as typeof process.stdout.write
181
+ // Forward encoding/callback (and the function-as-second-arg overload), not just chunk.
182
+ process.stdout.write = ((chunk, encoding, callback) =>
183
+ typeof encoding === "function"
184
+ ? process.stderr.write(chunk, encoding)
185
+ : process.stderr.write(chunk, encoding, callback)) as typeof process.stdout.write
145
186
  return (chunk) => protocolWrite(chunk)
146
187
  }
147
188
 
@@ -186,7 +227,11 @@ async function writePersistentResponse<Registry>(
186
227
  write: (chunk: string) => boolean,
187
228
  ): Promise<void> {
188
229
  try {
189
- const request = JSON.parse(line) as RenderRequest | RenderDocumentRequest | HealthRequest
230
+ const request = JSON.parse(line) as
231
+ | RenderRequest
232
+ | RenderDocumentRequest
233
+ | ParseDocumentRequest
234
+ | HealthRequest
190
235
  if (isHealthRequest(request)) {
191
236
  write(`${JSON.stringify(okResponse())}\n`)
192
237
  return
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.2.0"
2
- export const RENDER_PROTOCOL_VERSION = 2
1
+ export const VERSION = "0.4.0"
2
+ export const RENDER_PROTOCOL_VERSION = 3