Use linkme, not macro expansion, for Native.ts generation

This commit is contained in:
marc-signal 2026-05-15 14:16:26 -04:00 committed by GitHub
parent 7543c3d35b
commit 4d43a6270a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2584 additions and 2240 deletions

View File

@ -589,7 +589,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Verify that the Node bindings are up to date
run: rust/bridge/node/bin/gen_ts_decl.py --verify
run: cargo run -p libsignal-node-native_ts -- --verify
if: startsWith(matrix.os, 'ubuntu-')
- run: npm ci

View File

@ -154,7 +154,7 @@ jobs:
- run: sudo apt-get install -U protobuf-compiler
- name: Verify that the Node bindings are up to date
run: rust/bridge/node/bin/gen_ts_decl.py --verify
run: cargo run -p libsignal-node-native_ts -- --verify
publish:
name: Publish

31
Cargo.lock generated
View File

@ -2992,6 +2992,7 @@ dependencies = [
"libsignal-bridge",
"libsignal-bridge-macros",
"libsignal-bridge-testing",
"libsignal-bridge-types",
"libsignal-protocol",
"linkme",
"log",
@ -3005,6 +3006,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "libsignal-node-native_ts"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"libsignal-bridge",
"libsignal-bridge-testing",
"libsignal-bridge-types",
"libsignal-node",
"minijinja",
]
[[package]]
name = "libsignal-protocol"
version = "0.1.0"
@ -3206,6 +3220,12 @@ dependencies = [
"libc",
]
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "mime"
version = "0.3.17"
@ -3291,6 +3311,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "minijinja"
version = "2.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"
dependencies = [
"indexmap 2.13.0",
"memo-map",
"serde",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"

View File

@ -22,6 +22,7 @@ members = [
"rust/bridge/jni/impl",
"rust/bridge/jni/testing",
"rust/bridge/node",
"rust/bridge/node/native_ts",
]
default-members = [
"rust/crypto",
@ -67,6 +68,7 @@ libsignal-message-backup = { path = "rust/message-backup" }
libsignal-net = { path = "rust/net" }
libsignal-net-chat = { path = "rust/net/chat" }
libsignal-net-grpc = { path = "rust/net/grpc" }
libsignal-node = { path = "rust/bridge/node" }
libsignal-protocol = { path = "rust/protocol" }
libsignal-svrb = { path = "rust/svrb" }
poksho = { path = "rust/poksho" }
@ -160,6 +162,7 @@ mediasan-common = "0.5.3"
minidump = { version = "0.22.1", default-features = false }
minidump-processor = { version = "0.22.1", default-features = false }
minidump-unwind = { version = "0.22.1", default-features = false }
minijinja = "2.19.0"
mp4san = "0.5.3"
neon = { version = "1.1.0", default-features = false }
nonzero_ext = "0.3.0"

View File

@ -214,7 +214,7 @@ $ npm run test
When testing changes locally, you can use `npm run build` to do an incremental rebuild of the Rust library. Alternately, `npm run build-with-debug-level-logs` will rebuild without filtering out debug- and verbose-level logs.
When exposing new APIs to Node, you will need to run `rust/bridge/node/bin/gen_ts_decl.py` in
When exposing new APIs to Node, you will need to run `just generate-node` in
addition to rebuilding.
[nvm]: https://github.com/nvm-sh/nvm

View File

@ -47,8 +47,8 @@
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
<li><a href="#MIT">MIT License</a> (349)</li>
<li><a href="#AGPL-3.0-only">GNU Affero General Public License v3.0 only</a> (36)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (25)</li>
<li><a href="#AGPL-3.0-only">GNU Affero General Public License v3.0 only</a> (37)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (27)</li>
<li><a href="#BSD-3-Clause">BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License</a> (9)</li>
<li><a href="#ISC">ISC License</a> (4)</li>
<li><a href="#MPL-2.0">Mozilla Public License 2.0</a> (2)</li>
@ -741,6 +741,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
<li><a href="https://crates.io/crates/libsignal-node">libsignal-node</a></li>
<li><a href="https://crates.io/crates/signal-neon-futures">signal-neon-futures</a></li>
<li><a href="https://crates.io/crates/signal-neon-futures-tests">signal-neon-futures-tests</a></li>
<li><a href="https://crates.io/crates/libsignal-node-native_ts">libsignal-node-native_ts</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge">libsignal-bridge</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge-macros">libsignal-bridge-macros</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge-testing">libsignal-bridge-testing</a></li>
@ -2937,6 +2938,8 @@ END OF TERMS AND CONDITIONS
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href="https://github.com/getsentry/rust-debugid">debugid 0.8.0</a></li>
<li><a href="https://github.com/mitsuhiko/memo-map">memo-map 0.3.3</a></li>
<li><a href="https://github.com/mitsuhiko/minijinja">minijinja 2.19.0</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-build 0.14.1</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-derive 0.14.1</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-types 0.14.1</a></li>

View File

@ -14,7 +14,7 @@ generate-ffi:
swift/build_ffi.sh --generate-ffi
generate-node:
rust/bridge/node/bin/gen_ts_decl.py
cargo run -p libsignal-node-native_ts
alias generate-java := generate-jni
alias generate-swift := generate-ffi

View File

@ -24,8 +24,8 @@
"build": "python3 build_node_bridge.py",
"build-with-debug-level-logs": "python3 build_node_bridge.py --debug-level-logs",
"clean": "rimraf dist build prebuilds",
"format": "p() { prettier ${@:- --write} '**/*.{css,js,json,md,scss,ts,tsx}' ../rust/bridge/node/bin/Native.ts.in; }; p",
"format-check": "p() { prettier ${@:- --check} '**/*.{css,js,json,md,scss,ts,tsx}' ../rust/bridge/node/bin/Native.ts.in; }; p",
"format": "p() { prettier ${@:- --write} '**/*.{css,js,json,md,scss,ts,tsx}'; }; p",
"format-check": "p() { prettier ${@:- --check} '**/*.{css,js,json,md,scss,ts,tsx}'; }; p",
"install": "echo Use \\`npm run build\\` to build the native library if needed",
"lint": "eslint .",
"prepack": "cp ../acknowledgments/acknowledgments-desktop.md dist/acknowledgments.md",

File diff suppressed because it is too large Load Diff

View File

@ -80,8 +80,8 @@ class definition. For Swift, the `cbindgen` output is saved directly to a
C-style `.h` file that the Swift toolchain can consume.
For TypeScript, the [`libsignal-node`] crate is expanded and processed by
[`gen_ts_decl.py`](./node/bin/gen_ts_decl.py) and the output is interpolated into
[`Native.ts.in`](./node/bin/Native.ts.in). The output, however, only
[`libsignal-node-native_ts`](./node/native_ts/src/main.rs) and the output is interpolated into
[`Native.ts.in`](./node/native_ts/src/Native.ts.in). The output, however, only
declares the function signatures; to make them accessible to the JavaScript
runtime, additional machinery is used. This takes the form of `#[linkme]`
annotations on to the generated entry points; the [`linkme`] crate is used to

View File

@ -15,17 +15,23 @@ workspace = true
[lib]
name = "signal_node"
crate-type = ["cdylib"]
crate-type = ["cdylib", "lib"]
[features]
# Here for bridge_fn uniformity
node = []
default = ["node"]
metadata = [
"libsignal-bridge/metadata",
"libsignal-bridge-testing/metadata",
"libsignal-bridge-types/metadata",
]
[dependencies]
libsignal-bridge = { workspace = true, features = ["node", "signal-media"] }
libsignal-bridge-macros = { workspace = true }
libsignal-bridge-testing = { workspace = true, features = ["node", "signal-media"] }
libsignal-bridge-types = { workspace = true, features = ["node"] }
libsignal-protocol = { workspace = true }
futures = { workspace = true }

View File

@ -1,358 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (C) 2020-2021 Signal Messenger, LLC.
# SPDX-License-Identifier: AGPL-3.0-only
#
import collections
import difflib
import itertools
import os
import re
import subprocess
import sys
from typing import Iterable, Iterator, Tuple
Args = collections.namedtuple('Args', ['verify'])
def parse_args() -> Args:
def print_usage_and_exit() -> None:
print('usage: %s [--verify]' % sys.argv[0], file=sys.stderr)
sys.exit(2)
# If the command-line handling below gets any more complicated, this should be switched to argparse.
mode = None
if len(sys.argv) > 2:
print_usage_and_exit()
elif len(sys.argv) == 2:
mode = sys.argv[1]
if mode != '--verify':
print_usage_and_exit()
return Args(verify=mode is not None)
def split_rust_args(args: str) -> Iterator[Tuple[str, str]]:
"""
Split Rust `arg: Type` pairs separated by commas.
Account for templates, tuples, and slices.
"""
while ': ' in args:
(name, args) = args.split(': ', maxsplit=1)
if name.startswith('mut '):
name = name[4:]
open_pairs = 0
for (i, c) in enumerate(args):
if c == ',' and open_pairs == 0:
ty = args[:i]
args = args[i + 1:]
yield (name.strip(), ty.strip())
break
elif c in ['<', '(', '[']:
open_pairs += 1
elif c in ['>', ')', ']']:
open_pairs -= 1
else:
yield (name.strip(), args.strip())
def translate_to_ts(typ: str) -> str:
typ = typ.replace(' ', '')
type_map = {
'()': 'void',
'&[u8]': 'Uint8Array<ArrayBuffer>',
'i32': 'number',
'u8': 'number',
'u16': 'number',
'u32': 'number',
'u64': 'bigint',
'f64': 'number',
'bool': 'boolean',
'String': 'string',
'&str': 'string',
'Vec<u8>': 'Uint8Array<ArrayBuffer>',
'Box<[u8]>': 'Uint8Array<ArrayBuffer>',
'Box<[u32]>': 'Uint32Array<ArrayBuffer>',
'bytes::Bytes': 'Uint8Array<ArrayBuffer>',
'ServiceId': 'Uint8Array<ArrayBuffer>',
'Aci': 'Uint8Array<ArrayBuffer>',
'Pni': 'Uint8Array<ArrayBuffer>',
'E164': 'string',
"ServiceIdSequence<'_>": 'Uint8Array<ArrayBuffer>',
'PathAndQuery': 'string',
'LanguageList': 'string[]',
'GroupSendFullToken': 'Uint8Array<ArrayBuffer>',
'DeviceSpecifier': 'number',
'&BackupKey': 'Uint8Array<ArrayBuffer>',
'MultiRecipientSendAuthorization': 'Uint8Array<ArrayBuffer> | null',
'DisconnectCause': 'Error | null',
'::zkgroup::backups::BackupAuthCredential': 'Uint8Array<ArrayBuffer>',
'::zkgroup::generic_server_params::GenericServerPublicParams': 'Uint8Array<ArrayBuffer>',
}
if typ in type_map:
return type_map[typ]
if typ.startswith('[u8;') or typ.startswith('&[u8;'):
return 'Uint8Array<ArrayBuffer>'
if typ.startswith('&mutdyn'):
return typ[7:]
if typ.startswith('&dyn'):
return typ[4:]
if typ.startswith('&mut'):
return 'Wrapper<' + typ[4:] + '>'
if typ.startswith('&[&'):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[3:-1]) + '>[]'
if typ.startswith('Box<['):
assert typ.endswith(']>')
return translate_to_ts(typ[5:-2]) + '[]'
if typ.startswith('Box<dyn'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1])
if typ.startswith('Vec<'):
assert typ.endswith('>')
return translate_to_ts(typ[4:-1]) + '[]'
if typ.startswith('&['):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[2:-1]) + '>[]'
if typ.startswith('&'):
return 'Wrapper<' + typ[1:] + '>'
if typ.startswith('('):
assert typ.endswith(')'), typ
inner = typ[1:-1].split(',')
if len(inner) == 1:
return translate_to_ts(inner[0])
return '[' + ', '.join(translate_to_ts(x) for x in inner) + ']'
if typ.startswith('Option<'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1]) + ' | null'
if typ.startswith('Result<'):
assert typ.endswith('>')
type_args = typ[7:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('std::result::Result<'):
assert typ.endswith('>')
type_args = typ[20:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('Promise<'):
assert typ.endswith('>')
return 'Promise<' + translate_to_ts(typ[8:-1]) + '>'
if typ.startswith('CancellablePromise<'):
assert typ.endswith('>')
return 'CancellablePromise<' + translate_to_ts(typ[19:-1]) + '>'
if typ.startswith('AsType<'):
assert typ.endswith('>')
assert ',' in typ
return translate_to_ts(typ.split(',')[1][:-1])
if typ.startswith('Ignored<'):
assert typ.endswith('>')
return 'null'
return typ
DIAGNOSTICS_TO_IGNORE = [
r'warning: \d+ warnings? emitted',
r'warning: unused import',
r'warning: field.+ never read',
r'warning: variant.+ never constructed',
r'warning: method.+ never used',
r'warning: associated function.+ never used',
]
SHOULD_IGNORE_PATTERN = re.compile('(' + ')|('.join(DIAGNOSTICS_TO_IGNORE) + ')')
def camelcase(arg: str) -> str:
return re.sub(
# Preserve double-underscores and leading underscores,
# but remove single underscores and capitalize the following letter.
r'([^_])_([^_])',
lambda match: match.group(1) + match.group(2).upper(),
arg)
def rewrite_function_as_property(ts_function: str) -> str:
return ts_function.replace('(', ': (', 1).replace('):', ') =>')
def rewrite_fn(function_match: re.Match[str]) -> str:
(prefix, fn_args, ret_type) = function_match.groups()
ts_ret_type = translate_to_ts(ret_type)
ts_args = []
for (arg_name, arg_type) in split_rust_args(fn_args):
ts_arg_type = translate_to_ts(arg_type)
ts_args.append('%s: %s' % (camelcase(arg_name.strip()), ts_arg_type))
return '%s(%s): %s;' % (prefix, ', '.join(ts_args), ts_ret_type)
def rewrite_trait(decl: str, function_sig: re.Pattern[str]) -> Iterator[str]:
for line in decl.split('\\n'):
if function_match := function_sig.match(line.rstrip(';')):
yield ' ' + rewrite_function_as_property(rewrite_fn(function_match))
continue
# Fix backslash-escaped double-quotes.
yield bytes(line, 'utf-8').decode('unicode_escape')
def collect_decls(crate_dir: str, features: Iterable[str] = ()) -> Iterator[str]:
args = [
'cargo',
'rustc',
'-q',
'--profile=check',
'--features', ','.join(features),
'--message-format=short',
'--color=never',
'--',
'-Zunpretty=expanded']
rustc = subprocess.Popen(args, cwd=crate_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(raw_stdout, raw_stderr) = rustc.communicate()
stdout = str(raw_stdout.decode('utf8'))
stderr = str(raw_stderr.decode('utf8'))
had_error = False
for l in stderr.split('\n'):
if l == '':
continue
if SHOULD_IGNORE_PATTERN.search(l):
continue
print(l, file=sys.stderr)
had_error = True
if had_error:
print('Exiting with error')
sys.exit(1)
comment_decl = re.compile(r'\s*///\s*ts: `(.+)`')
# Note that the doc attribute is sometimes wrapped onto two lines.
attr_decl = re.compile(r'\s*(?:#\[doc\s*=\s*)?"ts: `(.+)`"\]')
# Make sure /not/ to match arguments with nested parentheses,
# which won't survive textual splitting below.
function_sig = re.compile(r'(.+)\(([^()]*)\): (.+)')
for line in stdout.split('\n'):
match = comment_decl.match(line) or attr_decl.match(line)
if match is None:
continue
(decl,) = match.groups()
if decl.startswith('export /*trait*/ type '):
yield '\n'.join(rewrite_trait(decl, function_sig))
continue
if function_match := function_sig.match(decl):
yield rewrite_fn(function_match)
continue
# Fix backslash-escaped double-quotes.
yield bytes(decl, 'utf-8').decode('unicode_escape')
def expand_template(template_file: str, decls: Iterable[str]) -> str:
decls = list(decls)
with open(template_file, 'r') as f:
contents = f.read()
# Rewrite from function syntax to property syntax to take advantage of
# https://www.typescriptlang.org/tsconfig/#strictFunctionTypes.
contents = contents.replace('NATIVE_FNS;', '\n '.join(
rewrite_function_as_property(x.removeprefix('export function '))
for x in decls if x.startswith('export function ')
))
contents = contents.replace('NATIVE_FN_NAMES', ''.join(
'\n ' + x.removeprefix('export function ').split('(')[0] + ','
for x in decls if x.startswith('export function ')
) + '\n')
contents = contents.replace('NATIVE_TYPES;', '\n'.join(
'export ' + x.removeprefix('export ') for x in decls if not x.startswith('export function ')
))
return contents
def verify_contents(expected_output_file: str, expected_contents: str) -> None:
with open(expected_output_file) as fh:
current_contents = fh.readlines()
diff = difflib.unified_diff(current_contents, expected_contents.splitlines(keepends=True))
first_line = next(diff, None)
if first_line:
sys.stdout.write(first_line)
sys.stdout.writelines(diff)
sys.exit(f'error: {expected_output_file} not up to date; re-run {sys.argv[0]}!')
Crate = collections.namedtuple('Crate', ['path', 'features'], defaults=[()])
def convert_to_typescript(rust_crates: Iterable[Crate], ts_in_path: str, ts_out_path: str, verify: bool) -> None:
decls = itertools.chain.from_iterable(collect_decls(crate.path, crate.features) for crate in rust_crates)
contents = expand_template(ts_in_path, decls)
if not os.access(ts_out_path, os.F_OK):
raise Exception(f"Didn't find {ts_out_path} where it was expected")
if not verify:
with open(ts_out_path, 'w') as fh:
fh.write(contents)
else:
verify_contents(ts_out_path, contents)
def main() -> None:
args = parse_args()
our_abs_dir = os.path.dirname(os.path.realpath(__file__))
output_file_name = 'Native.ts'
convert_to_typescript(
rust_crates=[
Crate(path=os.path.join(our_abs_dir, '..')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'types'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'testing'), features=('node', 'signal-media')),
],
ts_in_path=os.path.join(our_abs_dir, output_file_name + '.in'),
ts_out_path=os.path.join(our_abs_dir, '..', '..', '..', '..', 'node', 'ts', output_file_name),
verify=args.verify,
)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,19 @@
[package]
name = "libsignal-node-native_ts"
version = "0.1.0"
authors.workspace = true
license.workspace = true
edition = "2024"
[lints]
workspace = true
[dependencies]
libsignal-bridge = { workspace = true, features = ["node", "metadata"] }
libsignal-bridge-testing = { workspace = true, features = ["node", "metadata"] }
libsignal-bridge-types = { workspace = true, features = ["node", "metadata"] }
libsignal-node = { workspace = true, features = ["node", "metadata"] }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
minijinja = { workspace = true, features = ["preserve_order"] }

View File

@ -1,5 +1,5 @@
//
// Copyright 2020 Signal Messenger, LLC.
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
@ -134,18 +134,60 @@ export type Serialized<T> = Uint8Array<ArrayBuffer>;
type ConnectChatBridge = Wrapper<ConnectionManager>;
type TestingFutureCancellationGuard = Wrapper<TestingFutureCancellationCounter>;
// Keep in sync with rust/bridge/node/src/logging.rs
export const enum LogLevel { Error = 1, Warn, Info, Debug, Trace }
/* eslint-disable comma-dangle */
export const NetRemoteConfigKeys = [
{%- for key in remote_config_keys -%}
'{{ key }}',
{%- endfor -%}
] as const;
import load from 'node-gyp-build';
type NativeFunctions = {
registerErrors: (errorsModule: Record<string, unknown>) => void;
NATIVE_FNS;
initLogger: (maxLevel: LogLevel, callback: (level: LogLevel, target: string, file: string | null, line: number | null, message: string) => void) => void;
{%- for (name, f) in ctx.native_functions|items %}
{{ name }}: (
{%- for (name, ty) in f.arguments -%}
{{ name }}: {{ ty }},
{%- endfor -%}
) => {{ f.return_type }};
{%- endfor %}
};
const { registerErrors, NATIVE_FN_NAMES } = load(
{% macro native_fn_names(ctx) %}
{%- for (name, f) in ctx.native_functions|items %}
{{ name }},
{%- endfor %}
{% endmacro %}
const { registerErrors,
initLogger,
{{ native_fn_names(ctx) }}
} = load(
`${import.meta.dirname}/../`
) as NativeFunctions;
export { registerErrors, NATIVE_FN_NAMES };
export { registerErrors,
initLogger,
{{ native_fn_names(ctx)
}} };
/* eslint-disable comma-dangle */
NATIVE_TYPES;
{% for (name, fns) in ctx.bridge_traits|items %}
export /*trait*/ type {{ name }} = {
{%- for fn in fns %}
{{ fn.name }}: (
{%- for (arg, ty) in fn.body.arguments -%}
{{ arg }}: {{ ty }},
{%- endfor -%}
) => {{ fn.body.return_type }};
{%- endfor %}
};
{% endfor %}
{% for ty in ctx.opaque_types -%}
export interface {{ ty }} { readonly __type: unique symbol; }
{% endfor -%}

View File

@ -0,0 +1,51 @@
//
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
// To make sure the linkmes work
extern crate libsignal_bridge;
extern crate libsignal_bridge_testing;
extern crate signal_node;
use clap::Parser;
use libsignal_bridge_types::metadata::node::TsMetadataContext;
use libsignal_bridge_types::net::remote_config::RemoteConfigKey;
use minijinja::context;
#[derive(Parser)]
/// Regenerate Native.ts
///
/// This command assumes it's being invoked from the workspace root.
struct Cli {
/// Don't actually overwrite Native.ts, just make sure it's up-to-date.
#[clap(long)]
verify: bool,
}
fn main() -> anyhow::Result<()> {
let args = Cli::parse();
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.add_template("Native.ts.in", include_str!("Native.ts.in"))?;
let tmpl = env.get_template("Native.ts.in")?;
let mut ctx = TsMetadataContext::default();
for item in libsignal_bridge_types::metadata::node::NODE_ITEMS.iter() {
// We don't check item.module_path because, unlike other client languages, we emit both
// testing and non-testing native into the same typescript file.
(item.apply)(&mut ctx);
}
let code = tmpl.render(context! {
ctx => ctx,
remote_config_keys => RemoteConfigKey::KEYS,
})?;
let dst = "./node/ts/Native.ts";
if args.verify {
anyhow::ensure!(
std::fs::read_to_string(dst)? == code,
"Native.ts is not up-to-date"
);
} else {
std::fs::write(dst, code.as_bytes())?;
}
Ok(())
}

View File

@ -6,7 +6,7 @@
#![warn(clippy::unwrap_used)]
use futures::executor;
use libsignal_bridge::node::{AssumedImmutableBuffer, ResultTypeInfo, SignalNodeError};
use libsignal_bridge::node::ResultTypeInfo;
use libsignal_bridge::node_register;
use libsignal_bridge::support::*;
use libsignal_bridge_macros::bridge_fn;
@ -16,7 +16,6 @@ use minidump_processor::ProcessorOptions;
use minidump_unwind::Symbolizer;
use minidump_unwind::symbols::string_symbol_supplier;
use neon::prelude::*;
use neon::types::buffer::TypedArray;
use rand::TryRngCore;
use uuid::Uuid;
@ -31,11 +30,6 @@ use libsignal_bridge_testing::*;
fn main(mut cx: ModuleContext) -> NeonResult<()> {
libsignal_bridge::node::register(&mut cx)?;
cx.export_function("initLogger", logging::init_logger)?;
cx.export_function(
"SealedSenderMultiRecipientMessage_Parse",
sealed_sender_multi_recipient_message_parse,
)?;
cx.export_function("MinidumpToJSONString", minidump_to_json_string)?;
let remote_config_keys = libsignal_bridge::net::RemoteConfigKey::KEYS.convert_into(&mut cx)?;
cx.export_value("NetRemoteConfigKeys", remote_config_keys)?;
Ok(())
@ -67,91 +61,96 @@ impl<'a> From<ArrayBuilder<'a>> for Handle<'a, JsArray> {
}
}
/// ts: `export function SealedSenderMultiRecipientMessage_Parse(buffer: Uint8Array<ArrayBuffer>): SealedSenderMultiRecipientMessage`
fn sealed_sender_multi_recipient_message_parse(mut cx: FunctionContext) -> JsResult<JsObject> {
let buffer_arg = cx.argument::<JsUint8Array>(0)?;
let buffer = AssumedImmutableBuffer::new(&cx, buffer_arg);
let messages = match SealedSenderV2SentMessage::parse(&buffer) {
Ok(messages) => messages,
Err(e) => {
let throwable =
e.into_throwable(&mut cx, "sealed_sender_multi_recipient_parse_sent_message");
cx.throw(throwable)?
}
};
struct SealedSenderMultiRecipientMessage<'a>(SealedSenderV2SentMessage<'a>);
impl<'a, 'b> ResultTypeInfo<'a> for SealedSenderMultiRecipientMessage<'b> {
type ResultType = JsObject;
let recipient_map = cx.empty_object();
let mut excluded_recipients_array = ArrayBuilder::new(&mut cx);
fn convert_into(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::ResultType> {
let messages = self.0;
let recipient_map = cx.empty_object();
let mut excluded_recipients_array = ArrayBuilder::new(cx);
for (service_id, recipient) in &messages.recipients {
let service_id_string = cx.string(service_id.service_id_string());
if recipient.devices.is_empty() {
excluded_recipients_array
.push(service_id_string, &mut cx)
.expect("failed to construct output array");
continue;
for (service_id, recipient) in &messages.recipients {
let service_id_string = cx.string(service_id.service_id_string());
if recipient.devices.is_empty() {
excluded_recipients_array
.push(service_id_string, cx)
.expect("failed to construct output array");
continue;
}
let mut device_ids = ArrayBuilder::new(cx);
let mut registration_ids = ArrayBuilder::new(cx);
for &(device_id, registration_id) in &recipient.devices {
device_ids
.push(cx.number(u32::from(device_id)), cx)
.expect("failed to construct output array");
registration_ids
.push(cx.number(registration_id), cx)
.expect("failed to construct output array");
}
let range = messages.range_for_recipient_key_material(recipient);
let range_start = cx.number(u32::try_from(range.start).expect("message too large"));
let range_len = cx.number(u32::try_from(range.len()).expect("message too large"));
let recipient_object = cx.empty_object();
recipient_object
.set(cx, "deviceIds", device_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(cx, "registrationIds", registration_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(cx, "rangeOffset", range_start)
.expect("failed to construct recipient object");
recipient_object
.set(cx, "rangeLen", range_len)
.expect("failed to construct recipient object");
recipient_map
.set(cx, service_id_string, recipient_object)
.expect("failed to record recipient object");
}
let mut device_ids = ArrayBuilder::new(&mut cx);
let mut registration_ids = ArrayBuilder::new(&mut cx);
let offset_of_shared_bytes =
cx.number(u32::try_from(messages.offset_of_shared_bytes()).expect("message too large"));
for &(device_id, registration_id) in &recipient.devices {
device_ids
.push(cx.number(u32::from(device_id)), &mut cx)
.expect("failed to construct output array");
registration_ids
.push(cx.number(registration_id), &mut cx)
.expect("failed to construct output array");
}
let result = cx.empty_object();
result
.set(cx, "recipientMap", recipient_map)
.expect("failed to construct result object");
result
.set(cx, "excludedRecipients", excluded_recipients_array.into())
.expect("failed to construct result object");
result
.set(cx, "offsetOfSharedData", offset_of_shared_bytes)
.expect("failed to construct result object");
let range = messages.range_for_recipient_key_material(recipient);
let range_start = cx.number(u32::try_from(range.start).expect("message too large"));
let range_len = cx.number(u32::try_from(range.len()).expect("message too large"));
let recipient_object = cx.empty_object();
recipient_object
.set(&mut cx, "deviceIds", device_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "registrationIds", registration_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "rangeOffset", range_start)
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "rangeLen", range_len)
.expect("failed to construct recipient object");
recipient_map
.set(&mut cx, service_id_string, recipient_object)
.expect("failed to record recipient object");
Ok(result)
}
let offset_of_shared_bytes =
cx.number(u32::try_from(messages.offset_of_shared_bytes()).expect("message too large"));
let result = cx.empty_object();
result
.set(&mut cx, "recipientMap", recipient_map)
.expect("failed to construct result object");
result
.set(
&mut cx,
"excludedRecipients",
excluded_recipients_array.into(),
)
.expect("failed to construct result object");
result
.set(&mut cx, "offsetOfSharedData", offset_of_shared_bytes)
.expect("failed to construct result object");
Ok(result)
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(
_: &mut libsignal_bridge_types::metadata::node::TsMetadataContext,
) -> String {
"SealedSenderMultiRecipientMessage".into()
}
}
/// ts: `export function MinidumpToJSONString(buffer: Uint8Array<ArrayBuffer>): string`
fn minidump_to_json_string(mut cx: FunctionContext) -> JsResult<JsString> {
let buffer_arg = cx.argument::<JsUint8Array>(0)?;
let dump = Minidump::read(buffer_arg.as_slice(&cx)).expect("Failed to parse minidump");
#[bridge_fn(jni = false, ffi = false)]
fn SealedSenderMultiRecipientMessage_Parse(
buffer: &[u8],
) -> libsignal_protocol::error::Result<SealedSenderMultiRecipientMessage<'_>> {
Ok(SealedSenderMultiRecipientMessage(
SealedSenderV2SentMessage::parse(buffer)?,
))
}
#[bridge_fn(ffi = false, jni = false)]
fn MinidumpToJSONString(buffer: &[u8]) -> String {
let dump = Minidump::read(buffer).expect("Failed to parse minidump");
let provider = Symbolizer::new(string_symbol_supplier(std::collections::HashMap::new()));
let options = ProcessorOptions::default();
@ -165,7 +164,7 @@ fn minidump_to_json_string(mut cx: FunctionContext) -> JsResult<JsString> {
.print_json(&mut json, false)
.expect("Failed to print json");
Ok(cx.string(std::str::from_utf8(&json).expect("Failed to convert JSON to utf8")))
String::from_utf8(json).expect("Failed to convert JSON to utf8")
}
#[bridge_fn(ffi = false, jni = false)]

View File

@ -9,7 +9,7 @@ use std::sync::atomic::AtomicBool;
use libsignal_bridge::node::SimpleArgTypeInfo;
use neon::prelude::*;
/// ts: `export const enum LogLevel { Error = 1, Warn, Info, Debug, Trace }`
// Keep in sync with Native.ts.in
#[derive(Clone, Copy)]
enum LogLevel {
Error = 1,
@ -194,7 +194,6 @@ fn set_max_level_from_js_level(max_level: u32) {
log::set_max_level(log::Level::from(level).to_level_filter());
}
/// ts: `export function initLogger(maxLevel: LogLevel, callback: (level: LogLevel, target: string, file: string | null, line: number | null, message: string) => void): void`
pub(crate) fn init_logger(mut cx: FunctionContext) -> JsResult<JsUndefined> {
let max_level_arg = cx.argument::<JsNumber>(0)?;
let max_level = u32::convert_from(&mut cx, max_level_arg)?;

View File

@ -71,3 +71,4 @@ ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"]
node = ["neon", "linkme", "libsignal-bridge-types/node"]
signal-media = ["dep:signal-media", "libsignal-bridge-types/signal-media"]
metadata = ["libsignal-bridge-types/metadata"]

View File

@ -52,7 +52,7 @@
//! export function SenderKeyMessage_New(
//! keyId: number,
//! iteration: number,
//! ciphertext: Buffer,
//! ciphertext: Uint8Array<ArrayBuffer>,
//! pk: Wrapper<PrivateKey>
//! ): SenderKeyMessage;
//! ```
@ -132,8 +132,9 @@
//!
//! 1. Argument and result types for FFI and JNI are determined by macros `ffi_arg_type`,
//! `ffi_result_type`, `jni_arg_type`, and `jni_result_type`. You may need to add your new type
//! there. JNI and Node types also undergo some additional transformation in the scripts
//! `gen_java_decl.py` and `gen_ts_decl.py`, which you may need to tweak as well.
//! there. JNI types also undergo some additional transformation in the scripts
//! `gen_java_decl.py`, which you may need to tweak as well. Node types are generated as Strings
//! via the `gen_ts_ffi()` methods on `node::{AsyncArg, Arg, Result}TypeInfo`.
//!
//! 2. Argument types conform to one or more of the following bridge-specific traits:
//!

View File

@ -12,7 +12,7 @@ use syn::*;
use syn_mid::Signature;
use crate::BridgingKind;
use crate::util::{extract_arg_names_and_types, result_type};
use crate::util::{crates, extract_arg_names_and_types, result_type};
fn bridge_fn_body(orig_name: &Ident, input_args: &[(&Ident, &Type)]) -> TokenStream2 {
// Scroll down to the end of the function to see the quote template.
@ -182,9 +182,14 @@ pub(crate) fn bridge_fn(
let name_with_prefix = format_ident!("node_{}", name);
let name_without_prefix = Ident::new(name, Span::call_site());
let ts_signature_comment = generate_ts_signature_comment(name, sig, bridging_kind);
let input_args = extract_arg_names_and_types(sig)?;
let ts_metadata = generate_ts_metadata(
name,
sig.asyncness.is_some(),
&input_args,
result_type(&sig.output),
bridging_kind,
);
let body = match (sig.asyncness, bridging_kind) {
(Some(_), _) => bridge_fn_async_body(&sig.ident, name, bridging_kind, &input_args),
@ -200,51 +205,83 @@ pub(crate) fn bridge_fn(
Ok(quote! {
#[cfg(feature = "node")]
#[allow(non_snake_case)]
#[doc = #ts_signature_comment]
pub fn #name_with_prefix(
mut cx: node::FunctionContext,
) -> node::JsResult<node::JsValue> {
#body
}
#[cfg(all(feature = "metadata", feature = "node"))]
#ts_metadata
#[cfg(feature = "node")]
node_register!(#name_without_prefix);
})
}
/// Generates a string, containing the *Rust* signature of a bridged function, that gen_ts_decl.py
/// can use to generate Native.d.ts.
fn generate_ts_signature_comment(
/// Generates the code to embed `libsignal_bridge_types::metadata` metadata
fn generate_ts_metadata(
name_without_prefix: &str,
sig: &Signature,
asyncness: bool,
input_args: &[(&Ident, &Type)],
result_type: TokenStream2,
bridging_kind: &BridgingKind,
) -> String {
let mut ts_args = vec![];
) -> TokenStream2 {
let krate = crates::libsignal_bridge_types();
let mut input_args: Vec<_> = input_args
.iter()
.map(|(name, ty)| (name.to_string(), ty.to_token_stream()))
.collect();
match bridging_kind {
BridgingKind::Regular => {}
BridgingKind::Io { runtime } => {
ts_args.push(format!("async_runtime: &{}", runtime.to_token_stream()))
let runtime = runtime.to_token_stream();
input_args.insert(0, ("async_runtime".to_string(), quote!(&#runtime)))
}
}
ts_args.extend(
sig.inputs
.iter()
.map(|arg| arg.to_token_stream().to_string().replace('\n', " ")),
);
let result_type_format = match (sig.asyncness, bridging_kind) {
(Some(_), BridgingKind::Io { .. }) => |ty| format!("CancellablePromise<{ty}>"),
(Some(_), _) => |ty| format!("Promise<{ty}>"),
(None, _) => |ty| format!("{ty}"),
let argument_names = input_args
.iter()
.map(|(x, _)| to_lower_camel_case_preserve_underscores(x))
.collect_vec();
let argument_types = input_args.iter().map(|(_, x)| x).collect_vec();
let return_type_format = match (asyncness, bridging_kind) {
(true, BridgingKind::Io { .. }) => "CancellablePromise<{return_type}>",
(true, _) => "Promise<{return_type}>",
(false, _) => "{return_type}",
};
let result_type_str = result_type_format(result_type(&sig.output));
let md = quote!(#krate::metadata);
let metadata_name = format_ident!("_BRIDGE_NODE_METADATA_{name_without_prefix}");
let type_info_trait = if asyncness {
quote!(AsyncArgTypeInfo)
} else {
quote!(ArgTypeInfo)
};
quote! {
#[#md::linkme::distributed_slice(#md::node::NODE_ITEMS)]
#[linkme(crate = #md::linkme)]
static #metadata_name: #md::FnWithModule<#md::node::TsMetadataContext> = #md::FnWithModule {
module_path: module_path!(),
apply: |ctx| {
use #md::node::result_type_helper::*;
let return_type: ResultMetadataTransformHelper<#result_type> = Default::default();
let return_type = return_type.register_ts_ffi_type(ctx);
let mut arguments = Vec::new();
#(arguments.push((
#argument_names.into(),
<#argument_types as #krate::node::#type_info_trait>::register_ts_ffi_type(ctx)
));)*
ctx.native_functions.insert(
#name_without_prefix.into(),
#md::node::NativeFunction { arguments, return_type: format!(#return_type_format) },
);
},
};
}
}
format!(
"ts: `export function {}({}): {}`",
name_without_prefix,
ts_args.join(", "),
result_type_str
)
fn to_lower_camel_case_preserve_underscores(x: &str) -> String {
let x_sans_underscore = x.trim_start_matches('_');
let core = x_sans_underscore.to_lower_camel_case();
format!("{}{core}", &x[0..(x.len() - x_sans_underscore.len())])
}
pub(crate) fn name_from_ident(ident: &Ident) -> String {
@ -259,23 +296,20 @@ pub(crate) fn name_from_ident(ident: &Ident) -> String {
pub(crate) fn bridge_trait(trait_to_bridge: &ItemTrait, js_name: &str) -> Result<TokenStream2> {
let trait_name = &trait_to_bridge.ident;
let wrapper_name = format_ident!("Node{}", trait_to_bridge.ident);
let krate = crates::libsignal_bridge_types();
let callbacks = trait_to_bridge
.items
.iter()
.map(bridge_callback_item)
.map(|x| bridge_callback_item(x, &krate))
.collect::<Result<Vec<_>>>()?;
let callback_impls = callbacks.iter().map(|c| &c.implementation);
let callback_ts_decls = callbacks.iter().map(|c| &c.ts_decl);
let ts_declaration_comment = format!(
"ts: `export /*trait*/ type {js_name} = {{\n{}\n}};`",
callback_ts_decls.format("\n")
);
let callback_bridge_trait_functions = callbacks.iter().map(|c| &c.bridge_trait_function);
let md = quote!(#krate::metadata);
let metadata_name = format_ident!("_BRIDGE_NODE_METADATA_{trait_name}");
Ok(quote! {
#[cfg(feature = "node")]
#[doc = #ts_declaration_comment]
pub struct #wrapper_name(node::RootAndChannel);
#[cfg(feature = "node")]
@ -299,15 +333,29 @@ pub(crate) fn bridge_trait(trait_to_bridge: &ItemTrait, js_name: &str) -> Result
impl #trait_name for #wrapper_name {
#(#callback_impls)*
}
#[cfg(all(feature = "node", feature = "metadata"))]
#[#md::linkme::distributed_slice(#md::node::NODE_ITEMS)]
#[linkme(crate = #md::linkme)]
static #metadata_name: #md::FnWithModule<#md::node::TsMetadataContext> = #md::FnWithModule {
module_path: module_path!(),
apply: |ctx| {
let mut functions = Vec::new();
#(#callback_bridge_trait_functions)*
ctx.bridge_traits.insert(#js_name.to_string(), functions);
},
};
})
}
struct Callback {
implementation: TokenStream2,
ts_decl: String,
/// Push a `node::BridgeTraitFunction` onto the local `functions` Vec
/// `ctx: &mut TsMetadataContext` is in scope
bridge_trait_function: TokenStream2,
}
fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
fn bridge_callback_item(item: &TraitItem, krate: &TokenStream2) -> Result<Callback> {
let TraitItem::Fn(item) = item else {
return Err(Error::new(item.span(), "only fns are supported"));
};
@ -395,21 +443,35 @@ fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
}
};
// operation(foo: number): void;
let js_arg_decls = item.sig.inputs.iter().filter_map(|arg| match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(arg) => {
let Pat::Ident(arg_name) = &*arg.pat else {
// Diagnosed elsewhere.
return None;
};
Some(format!("{}: {}", arg_name.ident, arg.ty.to_token_stream()))
}
});
let args = item
.sig
.inputs
.iter()
.filter_map(|arg| match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(arg) => {
let Pat::Ident(arg_name) = &*arg.pat else {
// Diagnosed elsewhere.
return None;
};
Some((&arg_name.ident, &arg.ty))
}
})
.collect_vec();
let arg_names = args
.iter()
.map(|(x, _)| to_lower_camel_case_preserve_underscores(&x.to_string()))
.collect_vec();
let arg_types = args.iter().map(|(_, x)| x).collect_vec();
let result_ty = result_type(&sig.output);
let result_string = if sig.asyncness.is_some() {
let result_ty = result_type(&sig.output);
format!("Promise<{result_ty}>")
let return_type = if sig.asyncness.is_some() {
quote! {{
use #krate::metadata::node::result_type_helper::*;
let return_type: CallbackResultMetadataTransformHelper<#result_ty> = Default::default();
let return_type = return_type.register_ts_ffi_type(ctx);
format!("Promise<{return_type}>")
}}
} else {
if !matches!(sig.output, ReturnType::Default) {
return Err(Error::new(
@ -417,17 +479,25 @@ fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
"non-async callbacks with results are not supported for Node",
));
}
"void".to_owned()
quote!("void".to_string())
};
let ts_decl = format!(
"{}({}): {};",
js_operation_name,
js_arg_decls.format(", "),
result_string
);
Ok(Callback {
implementation,
ts_decl,
bridge_trait_function: quote! {
let mut arguments = Vec::new();
#(arguments.push((
#arg_names.to_string(),
<#arg_types as #krate::node::ResultTypeInfo>::register_ts_ffi_type(ctx),
));)*
let return_type = #return_type;
functions.push(#krate::metadata::node::BridgeTraitFunction {
name: #js_operation_name.to_string(),
body: #krate::metadata::node::NativeFunction {
arguments,
return_type,
},
});
},
})
}

View File

@ -9,6 +9,20 @@ use syn::spanned::Spanned;
use syn::*;
use syn_mid::{FnArg, Pat, PatType, Signature};
pub(crate) mod crates {
use super::*;
fn pkg_name() -> String {
std::env::var("CARGO_PKG_NAME").expect("Missing CARGO_PKG_NAME")
}
pub(crate) fn libsignal_bridge_types() -> TokenStream2 {
if pkg_name() == "libsignal-bridge-types" {
quote!(crate)
} else {
quote!(::libsignal_bridge_types)
}
}
}
/// Returns the tokens of the type in `output_as_written`, or `()` if no return type was written.
pub(crate) fn result_type(output_as_written: &ReturnType) -> TokenStream2 {
match output_as_written {

View File

@ -55,3 +55,4 @@ ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"]
node = ["dep:linkme", "dep:neon", "libsignal-bridge-types/node"]
signal-media = ["libsignal-bridge-types/signal-media"]
metadata = ["libsignal-bridge-types/metadata"]

View File

@ -105,6 +105,10 @@ impl<'storage, 'context: 'storage> node::ArgTypeInfo<'storage, 'context> for Nee
fn load_from(_stored: &'storage mut Self::StoredType) -> Self {
Self::None
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[cfg(feature = "node")]
@ -123,6 +127,10 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for NeedsCleanup {
// We only want to test that the storage is cleaned up, not the value passed into the wrapped function.
Self::None
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but always produces an error when "borrowed" from the
@ -162,6 +170,10 @@ impl node::SimpleArgTypeInfo for ErrorOnBorrow {
) -> node::NeonResult<Self> {
node::Context::throw_type_error(cx, "deliberate error")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but panics as it is "borrowed" from the app-provided
@ -199,6 +211,11 @@ impl node::SimpleArgTypeInfo for PanicOnBorrow {
) -> node::NeonResult<Self> {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but panics on the secondary "load" step after the "borrow"
@ -258,6 +275,11 @@ impl<'storage, 'context: 'storage> node::ArgTypeInfo<'storage, 'context> for Pan
fn load_from(_stored: &'storage mut Self::StoredType) -> Self {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[cfg(feature = "node")]
@ -276,6 +298,11 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for PanicOnLoad {
fn load_async_arg(_stored: &'storage mut Self::StoredType) -> Self {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ResultTypeInfo but always fails to produce a result.
@ -311,6 +338,11 @@ impl<'a> node::ResultTypeInfo<'a> for ErrorOnReturn {
fn convert_into(self, cx: &mut impl node::Context<'a>) -> node::JsResult<'a, Self::ResultType> {
cx.throw_type_error("deliberate error")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ResultTypeInfo but always panics when producing a result.
@ -347,6 +379,11 @@ impl<'a> node::ResultTypeInfo<'a> for PanicOnReturn {
) -> node::JsResult<'a, Self::ResultType> {
panic!("deliberate panic");
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[derive(derive_more::Deref)]
@ -430,6 +467,11 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for TestingFutureCancellationGua
fn load_async_arg(stored: &'storage mut Self::StoredType) -> Self {
stored.take().unwrap().0
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"TestingFutureCancellationGuard".into()
}
}
bridge_as_handle!(TestingFutureCancellationCounter);

View File

@ -84,6 +84,7 @@ jni-type-tagging = []
jni-invoke-annotated = []
extra-jni-checks = ["jni-type-tagging", "jni-invoke-annotated"]
node = ["neon", "linkme", "signal-neon-futures"]
metadata = ["linkme", "serde/derive"]
[target.'cfg(not(any(windows, target_arch = "x86")))'.dependencies]
# sha2's asm implementation uses standalone .S files that aren't compiled correctly on Windows,

View File

@ -6,6 +6,9 @@
#![allow(clippy::missing_safety_doc)]
#![deny(clippy::unwrap_used)]
#[cfg(feature = "metadata")]
pub mod metadata;
#[cfg(feature = "ffi")]
#[macro_use]
pub mod ffi;

View File

@ -0,0 +1,108 @@
//
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
//! This module provides metadata about the bridge layer which will be consumed downstream for
//! various purposes:
//!
//! - To emit `Native.ts`, see `libsignal-node-native_ts`
//!
//! While some metadata facilities are shared, they're specialized to each client language.
// This is pub so that it can be used in bridge macros.
pub use linkme;
use linkme::distributed_slice;
use serde::Serialize;
#[cfg(feature = "node")]
pub mod node {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
#[derive(Debug, Clone, Serialize, Default)]
pub struct TsMetadataContext {
pub opaque_types: BTreeSet<String>,
pub native_functions: BTreeMap<String, NativeFunction>,
pub bridge_traits: BTreeMap<String, Vec<BridgeTraitFunction>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NativeFunction {
/// (name, type)
pub arguments: Vec<(String, String)>,
pub return_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BridgeTraitFunction {
pub name: String,
pub body: NativeFunction,
}
/// These functions should mutate the attached [TsMetadataContext] to register their item.
#[distributed_slice]
pub static NODE_ITEMS: [FnWithModule<TsMetadataContext>];
/// See [crate::support]'s `transform_helper` for how this works, and the rationale.
///
/// These functions provide the metadata-side (`register_ts_ffi_type()`) of `.ok_if_needed()`
///
/// ```
/// # use libsignal_bridge_types::metadata::node::result_type_helper::*;
/// let x: ResultMetadataTransformHelper<i32> = Default::default();
/// assert_eq!(x.register_ts_ffi_type(&mut Default::default()).as_str(), "number");
/// let y: ResultMetadataTransformHelper<Result<i32, String>> = Default::default();
/// assert_eq!(y.register_ts_ffi_type(&mut Default::default()).as_str(), "number");
/// ```
pub mod result_type_helper {
use std::marker::PhantomData;
use derive_where::derive_where;
use crate::metadata::node::TsMetadataContext;
use crate::node::{CallbackResultTypeInfo, ResultTypeInfo};
#[derive_where(Default)]
pub struct ResultMetadataTransformHelper<T>(PhantomData<T>);
impl<'a, T: ResultTypeInfo<'a>> ResultMetadataTransformHelper<T> {
pub fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
pub trait ResultMetadataTransformHelperTrait {
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String;
}
impl<'a, T: ResultTypeInfo<'a>, E> ResultMetadataTransformHelperTrait
for ResultMetadataTransformHelper<Result<T, E>>
{
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
#[derive_where(Default)]
pub struct CallbackResultMetadataTransformHelper<T>(PhantomData<T>);
impl<T: CallbackResultTypeInfo> CallbackResultMetadataTransformHelper<T> {
pub fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
pub trait CallbackResultMetadataTransformHelperTrait {
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String;
}
impl<T: CallbackResultTypeInfo, E> CallbackResultMetadataTransformHelperTrait
for CallbackResultMetadataTransformHelper<Result<T, E>>
{
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
}
}
pub struct FnWithModule<Ctx> {
/// The module the function is defined in
pub module_path: &'static str,
pub apply: fn(&mut Ctx),
}

View File

@ -81,7 +81,6 @@ macro_rules! define_keys {
}
impl RemoteConfigKey {
#[doc = concat!("ts: `export const NetRemoteConfigKeys = [", $("'", $key, "', "),* ,"] as const;`")]
pub const KEYS: &[&str] = &[$($key),*];
#[cfg(test)]
const IDENTITIER_KEY_PAIRS: &[(&str, &str)] = &[

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,9 @@ impl<T, const LEN: usize> Array<T> for [T; LEN] {
pub trait FixedLengthBincodeSerializable: 'static {
/// Should be an actual byte array type, like `[u8; 7]`.
type Array: Array<u8> + for<'a> TryFrom<&'a [u8], Error = std::array::TryFromSliceError>;
#[cfg(feature = "metadata")]
fn name() -> String;
}
/// A wrapper type that indicates that `T` should be serialized across the bridges.

View File

@ -32,11 +32,13 @@ pub fn validate_serialization<'a, T: Deserialize<'a> + PartialDefault>(
macro_rules! bridge_as_fixed_length_serializable {
($typ:ident) => {
::paste::paste! {
// Declare a marker type for TypeScript, the same as bridge_as_handle.
// (This is harmless for the other bridges.)
#[doc = "ts: `interface " $typ " { readonly __type: unique symbol; }`"]
impl FixedLengthBincodeSerializable for $typ {
type Array = [u8; [<$typ:snake:upper _LEN>]];
#[cfg(feature = "metadata")]
fn name() -> String {
let name = stringify!($typ);
name.rsplit_once("::").map(|(_, x)| x).unwrap_or(name).into()
}
}
}
};