Skip to main content

rynl10n 0.2.2 copy "rynl10n: ^0.2.2" to clipboard
rynl10n: ^0.2.2 copied to clipboard

앱 업데이트·심사 없이 번역을 배포하는 오픈소스 원격 로컬라이제이션 SDK. 빌드타임에 구운 번들(fallback)과 런타임 원격 오버레이의 2계층으로 항상 완전한 번역을 보장한다.

rynl10n (Flutter/Dart SDK) #

Flutter/Dart SDK. 순수 Dart 코어라 dart test로 검증 가능(Flutter 위젯 불요). 코어 알고리즘 (JCS·resolve·매칭·카나리·정수 매칭)은 M0 TS 참조 구현과 골든 벡터로 바이트·해시·동작 정합.

설치 #

# 앱 pubspec.yaml
dependencies:
  rynl10n: ^0.2.0

pub.dev에 게시돼 있다 — rynl10n 0.2.0(2026-09-17 · 4개 SDK lockstep 6.5). dart pub add rynl10n 으로도 된다. 빈 프로젝트에서 pub get → t() 왕복은 0.1.0 게시 직후(2026-08-27)에 확인했다.

코어를 함께 고치는 중이라면 경로 의존으로 참조한다. 패키지 tarball에는 test/가 없다 (.pubignore — 테스트가 패키지 밖 fixtures/golden/을 읽어 tarball 안에서는 돌지 않는다):

dependencies:
  rynl10n:
    path: ../RynL10n/sdks/flutter

사용 #

import 'dart:convert';
import 'package:rynl10n/rynl10n.dart';

final client = RynL10nClient(
  bundle: Snapshot.fromJson(jsonDecode(bakedSnapshotJson)), // 빌드타임 bake된 fallback(실제로는 아래 parseBakedSnapshot)
  store: myDeliveryStore,                                   // 배포 플레인(정적) 접근 — 아래 "앱 적용 경로"
  context: ClientContext(appVersion: '3.2.1'),             // 어느 릴리스를 받을지(4.3)
  locale: Localizations.localeOf(context).toLanguageTag(), // 그 안에서 어느 언어를 읽을지(3.1)
  installId: localInstallId,                                // 카나리 버킷용 — 없으면 rollout<100 릴리스를 받지 않는다
);
client.refresh(manifest);                                  // manifest는 보통 RemoteDeliveryStore.update()가 넘긴다
final s = client.t('cart.title', locale: 'ja');            // 동기 — 항상 번들 fallback
  • 로케일 축은 매칭 축과 별개다: context는 어느 릴리스를 받을지(4.3), locale은 그 안에서 어느 언어를 읽을지(3.1). t()에 로케일을 주면 그것이, 없으면 locale이, 그것도 없으면 번들 기본 로케일이 쓰이며 어느 쪽이든 fallback 체인을 탄다. 코어는 플랫폼 API를 모르므로 기기 언어는 앱이 넘긴다 — Flutter는 Localizations.localeOf(context).toLanguageTag()(위젯 밖이면 PlatformDispatcher.instance.locale.toLanguageTag()), 순수 Dart는 rynl10n_io.dart의 ioDeviceLocale() (Platform.localeName의 POSIX 형식 ko_KR.UTF-8을 BCP 47 ko-KR로 정규화해 준다).
  • 갱신 이벤트: client.onCatalogUpdated((info) => ...). Flutter는 이를 ValueNotifier<int>로 감싸 ValueListenableBuilder로 리빌드(어댑터는 위젯 레이어, 이 패키지는 위젯 의존성 없음). 단 맞는 릴리스가 사라져 번들로 되돌아갈 때는 이 이벤트가 오지 않는다(릴리스 id가 없어서). 되돌림까지 화면에 반영하려면 update()가 끝난 뒤에도 리빌드를 건다.
  • installId가 없으면 카나리(rollout 100% 미만) 릴리스의 오버레이를 하나도 받지 않는다(번들로 남고 overlay_applied도 세지 않는다). 부분 배포를 쓸 거라면 첫 실행에 익명 UUID를 만들어 shared_preferences 등에 저장해 두고 넘긴다. 서버로는 보내지 않는다.
  • NFC 정규화는 package:unorm_dart, SHA-256은 package:crypto — JCS 결정성이 타 플랫폼과 일치.

앱 적용 경로 (6.3 / 6.4) #

myDeliveryStore 자리를 앱이 손수 채울 필요가 없다. 두 조각이 SDK에 들어 있다 — 번들 로더와 배포 플레인 HTTP 구현. iOS·Android·Web과 같은 계약을 지키며 시나리오 테스트도 1:1로 맞춰 두었다.

번들은 이 패키지가 굽지 않는다 — Dart용 bake CLI나 Flutter 빌드 훅은 없다. iOS (swift run rynl10n-bake … --stable-name <out>) 또는 Android(./gradlew rynl10nBake … -PstableName=true -Pout=<out>, JDK 21만 있으면 되고 Android SDK는 필요 없다) CLI로 앱의 assets/에 굽는다. 명령과 옵션은 Android 가이드 §3을 따른다. --stable-name을 빼면 파일명이 snapshot-<base>.json이 되어 아래 경로로 읽히지 않는다. 산출물은 자산으로 선언해야 번들에 실린다:

# 앱 pubspec.yaml
flutter:
  assets:
    - assets/rynl10n/
import 'dart:async';                         // unawaited
import 'package:rynl10n/rynl10n.dart';
import 'package:rynl10n/rynl10n_io.dart';   // dart:io 기본 어댑터

// ① 번들 로더 — Flutter 자산은 문자열로 온다
final bundle = parseBakedSnapshot(
  await rootBundle.loadString('assets/rynl10n/snapshot.json'),
  source: 'assets/rynl10n/snapshot.json',
);

// ② 배포 플레인 HTTP — manifest(ETag) → 릴리스 자가 선택 → 필요한 산출물만 → 원자적 스왑
final store = RemoteDeliveryStore(
  baseUrl: 'https://cdn.example.com',       // 배포 플레인(정적) — 관리 API 아님
  project: 'shop',
  fetch: ioDeliveryFetch(),
  cache: FileArtifactCache(await getApplicationCacheDirectory(), project: 'shop'),
);
final client = RynL10nClient(bundle: bundle, store: store, context: ClientContext(appVersion: '3.2.1'));
// 앱 시작 직후·포그라운드 복귀에 호출. 캐시가 없는 첫 실행이 오프라인이면 DeliveryException을
// 던지므로 삼킨다(화면은 번들로 그대로 뜬다). runApp 전에 await하지 않는다.
unawaited(store.update(client).catchError((_) => false));
  • 동기 조회 / 비동기 다운로드 분리: DeliveryStore는 동기 인터페이스라 화면이 네트워크를 기다리지 않는다. update()가 산출물을 먼저 캐시에 채운 뒤 동기 코어를 호출한다.
  • 불변 산출물은 영구 캐시(내용해시 URL). manifest만 ETag로 재검증하고, 오프라인이면 마지막 캐시로 진행한다. 캐시조차 없으면 DeliveryException(badStatus·unavailable·malformed).
  • HTTP·저장소 의존은 어댑터에만 있다. 코어(rynl10n.dart)는 DeliveryFetch·ArtifactCache를 주입받는 순수 Dart다.
  • 번들 로더의 파일 시스템 버전(loadBakedSnapshot(Directory)·loadBakedLockfile)도 어댑터에 있다.

어댑터 진입점 고르기 #

진입점 HTTP 백엔드 Flutter Web 추가 의존성 채우는 구멍
package:rynl10n/rynl10n_io.dart dart:io HttpClient ✗ (컴파일 불가) 없음 ioDeliveryFetch · ioPushConnect · ioTelemetryPost · FileArtifactCache
package:rynl10n/rynl10n_http.dart package:http ✓ http httpDeliveryFetch · httpPushConnect · httpTelemetryPost

웹을 포함해 하나로 가려면 rynl10n_http.dart를 쓴다 — package:http가 플랫폼마다 백엔드를 알아서 고른다(웹=BrowserClient/fetch, 그 외=dart:io).

import 'package:rynl10n/rynl10n.dart';
import 'package:rynl10n/rynl10n_http.dart';
import 'package:web/web.dart' as web;

final store = RemoteDeliveryStore(
  baseUrl: 'https://cdn.example.com',
  project: 'shop',
  fetch: httpDeliveryFetch(),              // 웹·모바일·데스크톱·서버 공통
  cache: CallbackArtifactCache(            // 웹은 파일 시스템이 없다
    read: (k) => web.window.localStorage.getItem('rynl10n:shop:$k'),
    write: (k, v) => web.window.localStorage.setItem('rynl10n:shop:$k', v),
    clear: () {                            // 이 프로젝트 키만 지운다 — localStorage.clear()는 오리진 전체를 지운다
      final s = web.window.localStorage;
      for (var i = s.length - 1; i >= 0; i--) {
        final k = s.key(i);
        if (k != null && k.startsWith('rynl10n:shop:')) s.removeItem(k);
      }
    },
  ),
);

package:web은 SDK가 끌고 오지 않는다 — 앱 pubspec.yaml에 web을 추가한다.

CallbackArtifactCache는 코어에 있고 의존성이 없다 — SDK가 저장소 패키지를 고르지 않기 위한 이음새라, 모바일에서 shared_preferences를 꽂는 데도 같은 클래스를 쓴다. 저장소가 던지는 실패 (사생활 모드·용량 초과)는 조용히 삼킨다.

갱신 자동화 — 폴링 · 실시간 푸시 (옵트인) #

update()를 앱이 직접 부르는 것 외에, 켜 두면 알아서 따라오는 두 갈래가 있다.

// ① 주기 폴링(기본 60초) — 갱신의 보장선. 배터리·트래픽은 앱이 정한다.
store.startPolling(client, interval: const Duration(seconds: 60));
// AppLifecycleState.paused 에서 store.stopPolling(), resumed 에서 다시 startPolling.

// ② 실시간 푸시(SSE) — publish 즉시 반영. 지연 단축용이라 폴링과 함께 켠다.
final push = ServerPushChannel(
  endpoint: 'https://admin.example.com',   // 알림(관리) 플레인 — CDN이 아니다
  project: 'shop',
  connect: ioPushConnect(),                // 또는 httpPushConnect()
);
push.startUpdating(client, store);         // 신호 → store.update(client)
// 백그라운드 전환 등에서 push.stop()

프레임은 "manifest가 바뀌었다"는 신호뿐이고, 번역 데이터는 여전히 배포 플레인에서 받는다 — 데이터 경로는 정적으로 유지된다(4.1). 연결이 끊기면 3초 → 최대 60초 백오프로 재연결하고, 그 사이 갱신은 폴링이 덮는다.

Flutter Web에서 httpPushConnect: 브라우저 백엔드는 XHR 기반이라 응답을 끝까지 모아 넘긴다 — SSE가 제때 도착하지 않는다. 웹에서는 EventSource를 PushConnect로 감싸 넣는다(코어는 함수 하나만 요구한다 — 예시는 lib/src/http_adapter.dart 문서 주석). 폴링이 보장선이라 그대로 둬도 기능은 깨지지 않고 지연만 남는다.

배포 건전성 텔레메트리 (옵트인, 9.3) #

대시보드 관측성 탭과 releases/{r}/health(카나리 판정의 입력)를 채우는 익명 집계다. 두 번 옵트인해야 한다 — 수집(RynL10nClient(telemetry: 'aggregate'))과 전송(리포터 생성).

final reporter = TelemetryReporter(
  endpoint: 'https://admin.example.com',   // 관리 플레인(업로드는 쓰기 경로)
  project: 'shop',
  post: ioTelemetryPost(),                 // 또는 httpTelemetryPost()
);
reporter.start(client);                    // 기본 5분 주기
// 백그라운드 전환처럼 확실히 올리고 싶은 시점: await reporter.flush(client);

올라가는 것은 서버가 정의한 5개 필드가 전부다(projectId·releaseId·event·count· appVersionBucket). 그 외 필드는 서버가 배치째 거부하므로(프라이버시 가드) 키 이름·번역 값·기기 식별자는 구조적으로 나갈 수 없다. 카나리 버킷의 installId도 보내지 않는다. appVersionBucket은 개별 빌드가 아니라 버전군이다(3.2.1 → 3.2). 전송 실패 시 카운트를 되돌려 다음 주기에 다시 올린다(실패 구간이 사라지면 거부율이 실제보다 낮게 보인다).

브라우저에서 반드시 확인할 것 — 배포 플레인 CORS #

배포 플레인이 앱과 다른 오리진이면(= CDN을 쓰면 거의 항상) 다음 응답 헤더가 필요하다. 셀프호스트 참조 서버는 이미 셋 다 보낸다(backend/src/storage/delivery-server.ts).

  • Access-Control-Allow-Origin — 없으면 브라우저가 응답 자체를 막는다.
  • Access-Control-Expose-Headers: ETag — ETag는 CORS 안전목록 응답 헤더가 아니다. 노출하지 않으면 JS가 etag를 읽지 못해 조건부 요청이 영영 성립하지 않는다(동작은 하지만 폴링마다 manifest를 전량 다시 받는다).
  • Access-Control-Allow-Headers: If-None-Match — 이 요청 헤더는 preflight를 유발한다.

헤더가 없어도 SDK는 죽지 않는다 — etag가 null이면 조건부 요청을 생략하고, 응답이 막히면 마지막 캐시(또는 번들 fallback)로 진행한다.

검증 #

cd sdks/flutter && dart pub get && dart test   # 골든 11 + 시나리오 5 + 앱 적용 경로 19 + http 어댑터 6
                                               # + 폴링·푸시·텔레메트리 8 + 로케일 축 5 = 54 tests

M4 기능 #

  • 정수 버전 매칭: ClientContext(buildNumber: 4210) + VersionMatch('integer-range', '>=42 <50').
  • 카나리(8.4): manifest rollout<100이면 sha256('$installId:$releaseId') 버킷(0..99) < rollout만 오버레이 수신. 안전 기본값 rollout 100(전체). installId=기기 로컬 익명 난수(서버 미전송). installId를 넘기지 않으면 rollout<100 릴리스는 받지 않는다.
0
likes
130
points
310
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

앱 업데이트·심사 없이 번역을 배포하는 오픈소스 원격 로컬라이제이션 SDK. 빌드타임에 구운 번들(fallback)과 런타임 원격 오버레이의 2계층으로 항상 완전한 번역을 보장한다.

Repository (GitHub)
View/report issues

Topics

#localization #i18n

License

Apache-2.0 (license)

Dependencies

crypto, http, unorm_dart

More

Packages that depend on rynl10n