
Company News
Free Business Plan Upgrades for Open Source Maintainers
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.
@sonenta/react-i18next
Advanced tools
React SDK for Sonenta: translations + realtime missing-key handler.
The React SDK for Sonenta. Resolve translations from the Sonenta CDN, fall back gracefully when a key is missing, and stream those missing keys back to your dashboard in real time so the team can fill them without redeploying.
npm install @sonenta/react-i18next
t() + <Trans> semanticsScope of what has been measured, so you can judge the risk yourself: the
API surface below is checked against this package's own source and test suite,
and the migration has been exercised end-to-end on one real codebase — a
React Native (Hermes) app. It has not been exercised on a web React app of
comparable size, and @sonenta/vue-i18n, @sonenta/svelte-i18n and
@sonenta/next make no equivalent claim: they are their own APIs, not
replacements for vue-i18n, svelte-i18n or next-intl.
1. Repoint the import — one find-and-replace across your codebase:
- import { useTranslation, Trans } from "react-i18next";
+ import { useTranslation, Trans } from "@sonenta/react-i18next";
2. Wrap your app once in <SonentaProvider> (replacing i18next.init +
I18nextProvider). Translations now load from the Sonenta CDN and missing
keys stream to your dashboard:
import { SonentaProvider } from "@sonenta/react-i18next";
<SonentaProvider token={…} projectUuid={…} defaultLocale="fr" fallbackLng="en">
<App />
</SonentaProvider>
No i18next.init, no backend module, no bundled JSON. See
Migrating from react-i18next for the full
compatibility notes.
import { SonentaProvider, useTranslation } from "@sonenta/react-i18next";
export function App() {
return (
<SonentaProvider
token={import.meta.env.VITE_SONENTA_TOKEN}
projectUuid={import.meta.env.VITE_SONENTA_PROJECT}
defaultLocale="fr"
fallbackLng="en"
namespaces={["common"]}
>
<Hello />
</SonentaProvider>
);
}
function Hello() {
const { t, i18n } = useTranslation("common");
if (!i18n.ready) return <span>Loading…</span>;
return <h1>{t("hello.title", { name: "Marc", defaultValue: "Hello {{name}}" })}</h1>;
}
The token is the API key minted in Org Settings → API Keys. For the
browser SDK use a project-scoped key with the missing:write scope and
nothing else — that key only sees missing-key writes for one project, which
is the safest exposure profile.
SonentaProviderinterface SonentaConfig {
token: string; // snt_live_<prefix>.<secret>
projectUuid: string;
defaultLocale: string; // BCP-47 (e.g. "fr", "fr-CA")
fallbackLng?: string | string[]; // fallback locale(s); variants also fall back to their base (fr-CA → fr)
namespaces?: string[]; // default ['common']
defaultNS?: string; // alias: default namespace for single-ns apps
apiBase?: string; // default 'https://api.sonenta.dev'
cdnBase?: string; // default 'https://cdn.sonenta.com'
languageCatalog?: LanguageMeta[]; // embed the language catalog (offline/SSR/RN); powers dir()/nativeName()
disableLanguageCatalog?: boolean; // skip the public GET /v1/languages fetch
version?: string; // version slug, default 'main' (in cache keys)
versionSlug?: string; // @deprecated alias of `version`
env?: 'prod' | 'dev'; // default 'prod' (drives fetch source)
keySeparator?: string | false; // false = flat (literal keys); else split (default '.'); auto-detected from the version when omitted
nsSeparator?: string | false; // 'ns:key' separator (default ':'); false to disable (keys may contain ':')
initialBundles?: Record<string, Record<string, object>>; // build-time snapshot (locale->ns->tree)
plugins?: SonentaPlugin[]; // e.g. @sonenta/feedback, @sonenta/realtime
fetchImpl?: typeof fetch; // 2.7.0 — the fetch used for EVERY request, for the instance's life
transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;
missingHandler?: 'send' | 'log' | 'off'; // default 'send'
flushIntervalMs?: number; // default 5000
flushBatchSize?: number; // default 50
missingEventsBufferSize?: number; // default 200
}
fetchImpl (2.7.0) — inject an authenticated fetch, a proxy, or a stub. It serves every
request for the whole life of the instance (bundles, key-style, catalog, manifest), including
after a language change. It is also the seam that lets you test the SDK offline, without
monkey-patching globalThis.fetch:
<SonentaProvider fetchImpl={async (url) => new Response(JSON.stringify(bundles[String(url)]))} …>
Before 2.7.0 this key was silently ignored on React — it typechecked, it looked supported, and the SDK hit the network anyway.
vue-i18n/svelte-i18n/nexthad honoured it all along.
version selects which published version's bundles to load
(/p/<project>/<version>/latest/...); it defaults to 'main' and is part
of the SDK's bundle cache keys, so two providers with different version
values never share cached bundles. versionSlug is a deprecated alias
of version (if both are set, version wins).
Removed in 0.9.0: the
liveUpdates/centrifugoWsUrl/centrifugoTokenEndpointconfig keys. Realtime updates now live in the separate@sonenta/realtimeplugin (see Realtime updates). Passing any of those keys throws a clear migration error.
### `useTranslation(namespaces?, { keyPrefix? })`
Returns react-i18next's exact shape — the **tuple** `[t, i18n, ready]`, which also carries
`.t` / `.i18n` / `.ready`. Both destructurings work, so a migrated call site compiles unchanged:
```tsx
const [t, i18n, ready] = useTranslation("common"); // tuple form
const { t, i18n, ready } = useTranslation("common"); // object form
const { t } = useTranslation(["checkout", "common"]); // ns list, resolved in order
const { t } = useTranslation("common", { keyPrefix: "cart" }); // t("total") -> cart.total
ready is ours, and it tells the truth: it flips when the CDN bundles are in. (react-i18next's
own ready would report true from the first render here — i18next has no backend in this SDK,
we fetch the bundles, so it believes every namespace is already loaded.)
useSuspense, bindI18n and i18n are accepted and ignored — this engine owns loading and never
suspends.
// Two call shapes — the native object form AND the react-i18next-style
// positional fallback (a string 2nd arg is the default value):
type TranslationFunction = {
(key: string, defaultValue: string, options?: Record<string, unknown>): string;
(key: string, options?: Record<string, unknown> & { defaultValue?: string }): string;
};
interface I18nInstance {
ready: boolean;
locale: string;
language: string; // alias of `locale`
setLocale(next: string): Promise<void>;
changeLanguage(next: string): Promise<void>; // alias of `setLocale`
t: TranslationFunction; // for out-of-React use via getI18n()
missingEvents: MissingKeyEvent[]; // newest first, capped buffer
flushMissing(): Promise<void>; // force-flush the pending batch
reload(opts?: { locale?: string; namespace?: string }): Promise<void>;
dir(lng?: string): 'ltr' | 'rtl'; // text direction (i18next parity); default active locale
nativeName(lng?: string): string | undefined; // endonym fallback when no Intl.DisplayNames
languageMeta(lng?: string): LanguageMeta | undefined; // full catalog entry
}
i18n.reload(opts?)Bust-refetches already-loaded bundles (bypassing the browser HTTP cache)
and re-renders. Without opts it refreshes every loaded (locale, ns)
bundle; pass { locale } and/or { namespace } to narrow. Returns once
all refetches settle. Useful for a manual "refresh translations" button,
and it's what @sonenta/realtime calls on a
translations_published push.
Variant fallback chain. An active regional variant resolves through its
base language automatically before the configured fallbackLng — the
fr-CA → fr → source chain (native i18next semantics; multi-subtag locales
truncate progressively, e.g. zh-Hant-TW → zh-Hant → zh). Set fallbackLng
to your project's source language to terminate the chain there:
<SonentaProvider {...config} defaultLocale="fr-CA" fallbackLng="en">
The CDN already serves a variant as a fully merged bundle, so this is
defense-in-depth (and it also covers keys you serve from initialBundles).
fallbackLng also accepts an ordered chain: fallbackLng={['fr', 'en']}.
Direction (RTL). i18n.dir(lng?) returns 'ltr' | 'rtl' for a locale
(default: active), so you can drive <html dir> or a container's dir:
const { i18n } = useTranslation();
useEffect(() => { document.documentElement.dir = i18n.dir(); }, [i18n.language]);
It reads the public language catalog's rtl (variants inherit from their base),
falling back to a built-in RTL-language list before/without the catalog.
Native names. i18n.nativeName(lng?) returns a language's endonym (e.g.
français (Canada)) — the fallback for runtimes without
Intl.DisplayNames (React Native/Hermes, SSR). For UI-localized names,
prefer Intl.DisplayNames(uiLocale, { type: 'language' }).of(code) and fall
back to nativeName() when it is unavailable. i18n.languageMeta(lng?)
returns the full catalog entry (rtl, script, parent_code,
plural_categories, …).
These read a small public catalog (GET {apiBase}/v1/languages, no auth,
CDN-cached) fetched best-effort on start(). Embed it with
languageCatalog={[…]} for offline/SSR/React Native, or skip the fetch with
disableLanguageCatalog.
await getI18n().reload(); // refresh all
await getI18n().reload({ locale: "fr", namespace: "common" });
<Trans>Since 2.7.0 this IS react-i18next's <Trans> — we hand it our t, so it resolves through the
Sonenta engine (registry, a11y overlays) while doing its own node-walking. That means nesting,
count plurals, tOptions, shouldUnescape, self-closing tags and components keyed by tag name
all work, and a <Trans> copied from a react-i18next codebase renders identically.
<Trans
i18nKey="cta.terms"
defaults="I accept the <0>terms</0> and <1>privacy policy</1>"
components={[<a href="/terms" />, <a href="/privacy" />]}
/>
// …and everything react-i18next's Trans can do:
<Trans i18nKey="inbox" count={n} values={{ name }} components={{ b: <b />, br: <br /> }} />
<Trans t={t} i18nKey="title" /> {/* resolves in the enclosing hook's namespace */}
Before 2.7.0
<Trans>was our own 25-line reimplementation whose entire markup support was one regex. It could not nest, could not docountplurals, and dropped<br/>. If you migrated from react-i18next before 2.7.0, your<Trans>sentences may have been rendering differently — upgrading restores them.
What was checked, item by item (each line below is exercised by a test in
tests/ — if you need certainty, read the test, not this list):
Hook return shape — useTranslation() returns react-i18next's tuple
and object form with ready: [t, i18n, ready] plus .t / .i18n /
.ready (src/hooks.ts:44). ready is ours (CDN bundles actually in),
not i18next's, which reports every namespace loaded from the first render.
Positional default value — t('key', 'Default text') works (so does
t('key', 'Hi {{name}}', { name })), alongside the native
t('key', { defaultValue }). No codemod needed for inline fallbacks.
changeLanguage / language — i18n.changeLanguage('en') (alias of
setLocale) and the i18n.language getter (alias of locale) are available.
Out-of-React access — getI18n() returns the active instance for use in
plain modules, stores, or helpers (the react-i18next standalone-singleton
pattern):
import { getI18n } from "@sonenta/react-i18next";
// anywhere after <SonentaProvider> has mounted:
const label = getI18n().t("nav.home", "Home");
await getI18n().changeLanguage("en");
getI18n() throws a clear error if no provider is mounted yet, and assumes a
single app-wide provider.
Default namespace — the default is ['common'] (not react-i18next's
'translation'). Migrants pass namespaces={['translation']}, or the
defaultNS="translation" alias for single-namespace apps.
Resolved by i18next: t('key', { count }) selects the CLDR plural form (key_one / key_other /
…) and context keys (key_male) work. Exact numeric forms (key_0, key_1) win over the category
form when the bundle carries them.
@sonenta/feedback and @sonenta/in-context show the strings on the current screen. They read
an on-screen key registry that this SDK fills, and it has two producers:
| your import | producer | drops on unmount? |
|---|---|---|
from "@sonenta/react-i18next" | per-render (our hook) | yes — leaves with the component |
from "react-i18next" | cumulative (the i18next.t patch) | no — accumulates for the app's lifetime |
Both are fed, and snapshot() is their union, so the add-ons are never empty on a populated
screen. But the cumulative half has no unmount signal: on a half-migrated app the panel lists keys
from screens you have already left. Finish the migration and the panel is exact.
This is the bug 2.7.0 fixes. Until 2.7.0 our own hook fed both producers, so the cumulative superset always won and the add-ons showed the app's entire history no matter how completely you had migrated. If your feedback panel lists strings from screens you left, upgrade to
>= 2.7.0.
Codemod — switch all imports in one shot (re-run as needed):
grep -rl "from 'react-i18next'" src \
| xargs sed -i '' "s|from 'react-i18next'|from '@sonenta/react-i18next'|g"
Everything react-i18next exports that a migrated file imports — useTranslation, Trans,
withTranslation, Translation, I18nextProvider, I18nContext, TransWithoutContext,
useSSR, withSSR, initReactI18next, setDefaults, getDefaults — is exported here, so the
find-and-replace compiles. (getI18n is ours: it returns the Sonenta engine. Reach the raw
i18next instance via getI18n().i18next.)
Dev-time check — is the migration actually finished?
import { keyRegistry } from "@sonenta/react-i18next";
if (__DEV__ && keyRegistry.hasMixedImports()) {
console.warn("some components still import react-i18next directly — the on-screen key list will over-report");
}
hasMixedImports() is true only when BOTH producers hold keys — i.e. a migration in progress. The
SDK already logs this once in dev. (The old isPopulated() answered "is anything wired?", which
said yes on exactly the broken app it was meant to catch.)By default keys are nested and split on . — t("hero.title") reads
{ hero: { title } }. If your project stores flat keys (literal keys that
may contain dots, e.g. "App Version 6.3.8"), set keySeparator={false} so
keys are looked up verbatim:
<SonentaProvider {...config} keySeparator={false}>
keySeparator={false} — flat (literal keys; dotted keys work, never split).keySeparator="." (default) or any string — nested, split on it.key_style / key_separator
from the version metadata on mount (best-effort; needs an API key with
project:read, otherwise it falls back to nested "."). Set keySeparator
explicitly to skip that lookup and guarantee the style.Resolution is literal-first: an exact bundle[key] always wins, so a dotted
key resolves even in nested mode without config (the nested split is the
fallback). The namespace separator is configurable too — nsSeparator (default
":"; false disables "ns:key" parsing so keys may contain ":").
t("hello.title").(locale, namespace) was already fetched but doesn't
contain hello.title. (i18n.ready === true and the bundle for that
tuple is in the "attempted" set — this is the gate.)MissingKeyEvent, dedups it within the instance, and
pushes it into the missingEvents ring buffer.flushIntervalMs (default 5s) — or sooner if the batch hits
flushBatchSize (default 50) — the SDK flushes the pending batch via
the transport.interface MissingKeyEvent {
key: string;
namespace: string;
language_code: string;
source_value?: string; // explicit defaultValue or fallback value; omitted when none (never the key name)
sdk_meta?: Record<string, unknown>; // SDK adds {lib, ver} always; `url` ONLY where `window` exists (so: web yes, React Native NO)
}
source_value carries the canonical default the SDK has — the defaultValue
you pass to t() (object or positional form), or the fallback-language bundle
value. When there is no default, it is omitted (the key name is in key),
so the backend never mistakes a key for a translation.
Without the gate, every t("…") call between mount and bundle resolution
would report a "missing" key — which is a lie (the bundle just hadn't
arrived yet). The first-paint flood would poison your dashboard. The SDK
holds reports until both:
i18n.ready === true (initial bundles loaded), AND(locale, namespace) bundle was actually fetched.You can see the gate in action with i18n.missingEvents — it stays empty
until the network round-trip completes.
Replace the default POST with anything — Storybook mock, in-app inspector, Cypress capture:
<SonentaProvider
{...config}
transport={(batch) => {
window.parent.postMessage({ type: "sonenta:missing", batch }, "*");
}}
>
...
</SonentaProvider>
The default delivery path is also exported if you need to wrap it:
import { defaultTransport, logTransport } from "@sonenta/react-i18next";
Zero-deploy translation updates (subscribe to the project's Centrifugo
translations: channel and bust-refetch on publish) live in the separate
@sonenta/realtime package — added as a plugin of this
provider, not configured here:
import { SonentaProvider } from "@sonenta/react-i18next";
import { sonentaRealtime } from "@sonenta/realtime/react";
<SonentaProvider
{...config}
env="dev"
plugins={[
sonentaRealtime({ wsUrl: "wss://rt.sonenta.dev/connection/websocket" }),
]}
>
<App />
</SonentaProvider>;
Under the hood the plugin calls i18n.reload(...) on
each translations_published push. Realtime is a dev-version-only feature
(it only subscribes when env: "dev").
The
liveUpdates/centrifugoWsUrl/centrifugoTokenEndpointconfig keys were removed in 0.9.0. Install@sonenta/realtimeand use the plugin instead.
Native apps (and SSR/web) can render real translations on the first paint and offline — before the first CDN fetch — by embedding a build-time snapshot:
import snapshot from "./sonenta-snapshot.json"; // { locale: { namespace: tree } }
<SonentaProvider {...config} initialBundles={snapshot}>
<App />
</SonentaProvider>;
initialBundles is keyed locale -> namespace -> tree (the same shape as the
CDN JSON). It is primed synchronously, so i18n.ready is true on the very
first render when the snapshot covers the active locale's namespaces. On mount
the provider fetches the CDN and swaps in fresh values with no flash; if
that fetch fails (offline), the snapshot stays as last-known-good. Keys absent
from the snapshot do not fire "missing" reports until a real fetch confirms.
sonenta snapshot (from @sonenta/cli) fetches the
current published bundles and writes the JSON module.https://cdn.sonenta.com/p/<project>/<version>/latest/<locale>/<ns>.json and
assemble them into { [locale]: { [namespace]: <tree> } }, then import it.Render surface-specific copy (desktop / mobile / tablet) on top of your
normal locale resolution. A base bundle applies to every surface; a sparse
surface overlay ({ns}.{surface}.json on the CDN) overrides individual
keys for that surface only. t() returns the overlay value when present, else
the base — composing cleanly with locale fallback and plurals.
// Initial surface + reactive viewport detection (web):
<SonentaProvider {...config} surface="desktop" surfaceBreakpoints={true}>
<App />
</SonentaProvider>;
surface sets the initial surface (omit it to disable surface resolution
entirely — fully back-compatible).
surfaceBreakpoints={true} enables reactive detection from the viewport on
web (the provider maps window.innerWidth → surface via matchMedia and
calls setSurface on boundary crossings). Pass custom thresholds with
surfaceBreakpoints={{ mobile: 640, tablet: 1024 }}.
React Native (no window): set the initial surface, then drive changes
yourself from useWindowDimensions:
import { surfaceForWidth } from "@sonenta/react-i18next";
const { width } = useWindowDimensions();
useEffect(() => { i18n.setSurface(surfaceForWidth(width)); }, [width]);
Imperative: i18n.surface, i18n.setSurface("mobile").
Asset variants (minimal v1): an overlay key may carry
{ "$value": "...", "$asset": { kind, ref } }. t(key) returns $value;
read the companion ref with i18n.asset(key, ns?).
Plurals work the same in overlays (single key + CLDR plural forms); an overlay
plural set fully replaces the base key's. Surface overlays are served from the
CDN; env: "dev" is base-only for now.
A11y variants attach SEMANTIC accessibility text to a key — aria_label,
alt_text, screen_reader, plain_language — delivered through the same
sparse-overlay engine as device surfaces, but applied orthogonally to the
visible text (an element has both its visible label AND an accessible name).
Opt in per surface; they load alongside the base bundles.
<SonentaProvider {...config} a11ySurfaces={["aria_label", "alt_text"]}>
<App />
</SonentaProvider>
function SaveButton() {
const { t } = useTranslation("common");
return (
<button aria-label={t.aria("save")}>{t("save")}</button>
);
// t("save") → visible text ("Save")
// t.aria("save") → aria_label overlay, or the visible text if no override
}
function Hero() {
const { i18n } = useTranslation();
return <img src={i18n.a11yAsset("hero")?.ref} alt={i18n.alt("hero")} />;
}
t.aria(key) / t.alt(key) — overlay value, falling back to the visible
text when no a11y override exists. Also on the instance: i18n.aria() /
i18n.alt().t.a11y(key, surface) / i18n.a11y(key, surface) — the raw resolver:
returns undefined when there's no override (use for screen_reader /
plain_language, which should be omitted rather than fall back).i18n.a11yAsset(key) — the alt_text overlay's localized-image $asset.plain_language (or pass
plainLanguage) and call i18n.setPlainLanguage(true) — t() then returns
the plain_language overlay for keys that have one (else the base text).Resolution is locale-outer / surface-inner (same chain as t()): (fr-CA, aria_label) > (fr-CA, base) > (fr, aria_label) > (fr, base). Overlays are
CDN-only (env: "dev" is base-only).
Keys carry a semantic type (image, icon, button, text, …). You
pick the helper by the element you render — the SDK resolves the right
value automatically (overlay ?? base), so you don't need the type at runtime:
<img alt={t.alt("hero_image")} /> {/* image: base IS the alt */}
<button aria-label={t.aria("save_icon")}> {/* icon: base IS the accessible name */}
<button aria-label={t.aria("submit")}>OK {/* button: aria_label refines the label */}
image → the base value is the alt; t.alt(key) returns it.icon → the base value is the accessible name; t.aria(key) returns it.button / link / inputs → t.aria(key) returns the aria_label refinement
(falling back to the visible label).plain_language / screen_reader treatments apply; the backend
only publishes the treatments relevant to a key's type, so nothing else
resolves.For type-aware UIs (e.g. an editor), the capability map is exported:
A11Y_TREATMENTS_FOR, treatmentsFor(type), baseRoleFor(type), and the
KeyType union — mirroring the backend.
List the languages published for the active version at runtime — so adding
a language in Sonenta makes it appear in your switcher without recompiling
the app. The set comes from a per-version CDN manifest (public, cacheable,
no auth); each code is enriched from the language catalog (native_name,
rtl, …).
import { useTranslation, useAvailableLanguages } from "@sonenta/react-i18next";
function LanguageSwitcher() {
const { i18n } = useTranslation();
const languages = useAvailableLanguages(); // [{ code, native_name, rtl, is_default, published_at? }]
return (
<select value={i18n.language} onChange={(e) => i18n.setLocale(e.target.value)}>
{languages.map((l) => (
<option key={l.code} value={l.code}>
{l.native_name ?? l.code}
</option>
))}
</select>
);
}
i18n.reload() — no rebuild, no redeploy
of your app.is_default marks the project's default locale; published_at (when the
manifest provides it) lets you flag recently-added languages.defaultLocale + fallbackLng when no manifest is available
(or env: "dev", which is CDN-only). Opt out with disableLanguageManifest.i18n.availableLanguages.Wrap the SDK in a Client Component and feed it env vars from .env.local:
// app/(sonenta)/i18n-client.tsx
"use client";
import { SonentaProvider } from "@sonenta/react-i18next";
export function I18nClient({ children }: { children: React.ReactNode }) {
return (
<SonentaProvider
token={process.env.NEXT_PUBLIC_SONENTA_TOKEN!}
projectUuid={process.env.NEXT_PUBLIC_SONENTA_PROJECT!}
defaultLocale="fr"
fallbackLng="en"
>
{children}
</SonentaProvider>
);
}
The provider reads the bundle via the public CDN — no server-side state to
hydrate. SSR pre-renders the defaultValue and the client smoothly
upgrades after i18n.ready flips.
// .storybook/preview.tsx
import { SonentaProvider } from "@sonenta/react-i18next";
export const decorators = [
(Story) => (
<SonentaProvider
token="snt_live_storybook.fake"
projectUuid="storybook"
defaultLocale="fr"
missingHandler="log"
transport={(batch) => action("missing-keys")(batch)}
>
<Story />
</SonentaProvider>
),
];
cy.intercept("POST", "**/v1/missing", (req) => {
cy.task("captureMissing", req.body);
req.reply({ accepted: req.body.events.length, rejected: 0, items: [] });
});
Semver. V1.x will keep the public API stable. Internal changes (bundle fetcher, dedup heuristics) may shift in patch releases.
Breaking changes pre-V1 are flagged in CONTRACT.md.
MIT — see LICENSE.
FAQs
React SDK for Sonenta: translations + realtime missing-key handler.
We found that @sonenta/react-i18next demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Company News
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.

Security News
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.

Security News
During a UK cyber test, a Mythos 5 agent used sockpuppets, social engineering, and prompt injection to try to get a maintainer to merge malware.