defmodule RpcElixir.Codegen do
@moduledoc """
Generates TypeScript type definitions directly from an RPC router.
"""
import RpcElixir.Codegen.Shared, only: [paginated_item_type: 1]
alias RpcElixir.Codegen.Brands
alias RpcElixir.Codegen.Render
alias RpcElixir.Codegen.Shared
alias RpcElixir.Codegen.Structs
alias RpcElixir.Codegen.Tree
@doc """
Generates TypeScript source from a router module.
Options:
* `:client_import` — the import specifier for the client package
(default: `"@elixir-ts-rpc/client"`)
* `:out` — path of the output file. When set, handler source links in the
generated JSDoc are emitted as absolute `file://` URIs (clickable in an
editor); otherwise they are repo-root-relative paths.
"""
@spec generate(module(), keyword()) :: String.t()
def generate(router_mod, opts \\ []) do
client_import = Keyword.get(opts, :client_import, "@elixir-ts-rpc/client")
# The process-dictionary keys set here must be cleared even if generation
# raises (e.g. a brand collision), or a leaked value would bleed into a
# later generate/1 call in the same process (test/compiler processes).
try do
if out = Keyword.get(opts, :out) do
Process.put(:__rpc_out_dir__, Path.dirname(Path.expand(out)))
end
procedures = router_mod.__procedures__()
def_keys = Render.build_def_keys(procedures)
name_map = Render.build_name_map(def_keys)
proc_tree = Tree.build_proc_tree(procedures)
struct_types = Structs.collect_struct_types(procedures, name_map)
Brands.validate_brand_collisions!(procedures, struct_types, name_map)
branded_block = Brands.emit_branded_types(procedures)
parts = [
header(client_import, procedures, branded_block),
Structs.emit_struct_type_defs(struct_types),
Render.emit_all_defs(procedures, name_map, struct_types),
Tree.emit_rpc_client_type(proc_tree, name_map),
Tree.emit_create_rpc_client(proc_tree, name_map)
]
result = Enum.join(parts, "\n")
warn_missing_abstract_code()
result
after
Process.delete(:__rpc_missing_abstract_code__)
Process.delete(:__rpc_out_dir__)
end
end
defp header(client_import, procedures, branded_block) do
paginated_type =
if uses_paginated?(procedures) do
"export type PaginatedResponse<T> = { items: T[]; next_cursor: string | null; has_more: boolean };\n"
else
""
end
imports = Enum.join(client_imports(procedures), ", ")
"""
// AUTO-GENERATED by mix rpc.gen.ts — do not edit.
// Regenerate via: mix rpc.gen.ts --router YourApp.Router --out path/rpc.gen.ts
import { #{imports} } from #{JSON.encode!(client_import)};
#{paginated_type}#{branded_block}
"""
end
# Only import the error-shape types the generated body actually references, so the
# output stays clean under TS's verbatim module syntax: `DomainError` when any
# handler declares a coded error union, `MiddlewareError` when any procedure's
# middleware advertises `rpc_error_codes/1`.
defp client_imports(procedures) do
base = ["createClient", "rpcMethod", "type Client", "type RpcMethod"]
base ++
if(any_domain_error?(procedures), do: ["type DomainError"], else: []) ++
if(any_middleware_error?(procedures), do: ["type MiddlewareError"], else: [])
end
defp any_domain_error?(procedures) do
Enum.any?(procedures, fn proc -> Render.error_code_values(proc.error) != [] end)
end
defp any_middleware_error?(procedures) do
Enum.any?(procedures, fn proc -> Shared.middleware_error_codes(proc) != [] end)
end
defp uses_paginated?(procedures) do
Enum.any?(procedures, fn proc ->
paginated_ir?(proc.input) or paginated_ir?(proc.output)
end)
end
defp paginated_ir?(nil), do: false
defp paginated_ir?(%{kind: "object", fields: fields}),
do: match?({:ok, _}, paginated_item_type(fields))
defp paginated_ir?(%{kind: k, inner: inner})
when k in ["nullable", "optional", "list", "custom"],
do: paginated_ir?(inner)
defp paginated_ir?(_), do: false
defp warn_missing_abstract_code do
case Process.get(:__rpc_missing_abstract_code__, []) do
[] ->
:ok
modules ->
names = modules |> Enum.reverse() |> Enum.map_join(", ", &inspect/1)
Mix.shell().info(
"[rpc_elixir] handler source links skipped for #{names} — compile with debug_info: true to enable"
)
end
end
end