@plott/webkit-native0.4.0
앱 WebView 안의 웹이 쓰는 네이티브 기능 — 한 이름의 전송(iOS WKScriptMessageHandler·Android WebMessageListener/JavascriptInterface), 요청·응답(id·제한 시간·미지원 동작), 네이티브 이벤트(리스너가 붙기 전 이벤트 보관·중복 제거), UA 표식으로 앱·버전·기능 판정(서버와 브라우저가 같은 판정). 검증기는 Standard Schema(zod 등)로 받는다. 의존성 없음
설치
pnpm add @plott/webkit-native@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-native | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-native/react | ./dist/react/index.d.ts | ./dist/react/index.js |
앱 WebView 안의 웹이 앱(iOS·Android)과 주고받는 통로입니다. 읽는 사람은 앱 WebView 에서 도는 화면에 공유·저장·햅틱·딥링크·네이티브 로그인 같은 앱 기능을 붙이는 프론트엔드 개발자입니다. 앱 개발자가 맞출 계약은 네이티브 브릿지 계약 에 있습니다.
런타임 의존성이 없습니다. 메시지 검증기는 Standard Schema 를 구현한 라이브러리(zod 4·valibot·arktype)를 그대로 받습니다.
진입점
| import | 주요 export |
|---|---|
@plott/webkit-native | createBridge, BridgeError, parseAppUserAgent·buildAppUserAgent·isAppUserAgent·compareVersions, asAuthBridge |
@plott/webkit-native/react | useNativeEvent |
붙이기
bridge.ts 에 만들고 "use client" 컴포넌트가 import 합니다. ios: "app" 는 window.webkit.messageHandlers.app, android: "appNative" 는 window.appNative.postMessage(문자열), userAgentToken: "AppShell" 는 UA 끝의 AppShell/1.4.0 (ios; mode=host; caps=share) 입니다.
import { createBridge } from "@plott/webkit-native";import { z } from "zod";
export const bridge = createBridge({ ios: "app", android: "appNative", userAgentToken: "AppShell", contextGlobal: "__APP_NATIVE_CONTEXT__", messages: { share: z.object({ url: z.string().url(), title: z.string().optional() }) }, events: { navigate: z.object({ url: z.string() }) },});post 는 보내고 끝, request 는 id 와 제한 시간으로 응답을 기다리고, on 은 앱 이벤트를 듣고, supports 는 기능이 있는 앱인지 알려 줍니다.
import type { createBridge, StandardSchema } from "@plott/webkit-native";
declare const bridge: ReturnType< typeof createBridge<{ share: StandardSchema<{ url: string; title?: string }> }, { navigate: StandardSchema<{ url: string }> }>>;declare const url: string;declare const title: string;declare const router: { push(href: string): void };declare const openShareSheet: () => void;
bridge.post("share", { url, title });const photo = await bridge.request<{ key: string }>("pickPhoto");const off = bridge.on("navigate", ({ url }) => router.push(url));if (bridge.supports("share")) { openShareSheet();}서버 컴포넌트에서 앱인지 판정할 때는 요청의 User-Agent 로 같은 판정을 합니다.
import { isAppUserAgent } from "@plott/webkit-native";
declare const headers: () => Promise<Headers>;
const inApp = isAppUserAgent((await headers()).get("user-agent"), "AppShell");네이티브 로그인은 같은 브릿지의 요청 하나로 받습니다. @plott/webkit-auth 의 authClient 는 기본으로 브릿지를 쓰지 않으므로, asAuthBridge 로 만든 어댑터를 nativeBridge 로 넘깁니다. 웹 → 앱 메시지는 { action: "socialLogin", id, provider }, 앱의 응답은 window.__webkitBridge.resolve(id, { token, tokenType? }) 입니다. 동작 이름과 제한 시간(기본 2분)은 asAuthBridge(bridge, { action, timeoutMs }) 로 바꿉니다.
import { asAuthBridge, createBridge } from "@plott/webkit-native";import { authClient } from "@plott/webkit-auth/client";
const bridge = createBridge({ ios: "app", android: "appNative" });
authClient({ nativeBridge: asAuthBridge(bridge) });로그인 실패 코드는 BridgeError.code(timeout·unavailable·rejected 또는 앱이 reject 에 실은 문자열)가 그대로 AuthClientError.code 가 됩니다.
createBridge 가 돌려주는 메서드입니다.
| 메서드 | 하는 일 |
|---|---|
available() | 이 화면이 브릿지를 가진 앱 WebView 안이면 true |
inApp() | 브릿지 전역이 있거나 UA 표식이 있으면 true. 서버에서는 isAppUserAgent 로 같은 판정을 합니다 |
platform() | "ios"·"android", 모르면 null |
userAgent() | UA 표식을 읽은 값(parseAppUserAgent), 없으면 null |
context() | contextGlobal 의 값, 없으면 null |
capabilities() | 앱이 알려 준 기능 목록. UA 의 caps 가 먼저이고 없으면 문맥 전역의 capabilities. 모르면 null |
supports(capability) | 앱이 그 기능을 켰으면 true. 기능 목록을 모르면 false |
post(name, payload?) | 응답 없이 보냅니다. 앱이 없으면 false |
request(name, payload?, { timeoutMs, capability, signal }) | 보내고 응답을 기다립니다. 앱은 window.<callback>.resolve(id, data) 또는 .reject(id, { error }) 로 답합니다. 기능 목록을 아는데 capability 가 없으면 보내지 않고 unsupported_action 으로 거절합니다 |
on(name, handler) | 앱 이벤트를 듣고 해제 함수를 돌려줍니다. 앱은 window.<callback>.emit(name, detail, { id }) 로 보냅니다(귀를 대기 전이면 보관했다가 on 이 붙을 때 줍니다). window.dispatchEvent(new CustomEvent(prefix + name, { detail })) 로 직접 보내도 받지만 그때는 보관·중복 제거가 없습니다 |
BridgeError.code 는 unavailable(앱이 없거나 보낼 수 없음), timeout, unsupported_action, invalid_message(보내는 메시지가 검증기에 맞지 않음), invalid_response, rejected(코드 없는 거절), native_error(문자열이 아닌 거절 코드) 중 하나이거나, 앱이 reject 에 실은 문자열 코드입니다. reject 의 message 가 문자열이면 BridgeError.message 가 됩니다.
compareVersions(a, b) 는 a < b 면 -1, 같으면 0, 크면 1 을 돌려줍니다. buildAppUserAgent({ token, version, platform, params, capabilities }) 는 앱이 UA 끝에 붙일 접미 <token>/<version> (<platform>[; key=value][; caps=a,b]) 를 만들어 줍니다. useNativeEvent(bridge, name, handler) 는 컴포넌트 수명 동안 bridge.on 으로 듣고, handler 가 바뀌어도 다시 붙지 않습니다(보관된 이벤트를 두 번 받지 않습니다).
| 옵션 | 기본값 | 뜻 |
|---|---|---|
ios·android | (필수) | 플랫폼마다 이름 하나. iOS 는 window.webkit.messageHandlers.<ios>.postMessage(객체), Android 는 window.<android>.postMessage(JSON 문자열) |
window | 전역 window | 테스트에서 가짜 창을 줍니다 |
androidLegacy | 없음 | 옛 Android 인터페이스 하나({ object, method }). 셸이 모두 바뀌면 지웁니다 |
callback | __webkitBridge | 앱이 응답·이벤트를 넣는 전역. 기본값은 이 키트의 중립 이름이라, 앱마다 자기 셸이 쓰는 이름을 넘깁니다(예: callback: "__myAppBridge") |
envelopeKey | action | 동작 이름을 싣는 키({ action: "share", url }). type 을 쓰는 앱이면 바꿉니다 |
eventPrefix | native: | 앱 이벤트의 CustomEvent 이름 접두 |
userAgentToken | 없음 | UA 표식 토큰. 없으면 UA 로 판정하지 않습니다 |
contextGlobal | 없음 | 앱이 문서 시작 때 넣는 문맥(안전 영역·기능·버전) 전역 |
messages·events·responses | 없음 | 동작별 보내는 메시지·받는 이벤트·request 응답 검증기 |
timeoutMs | 30초 | request 의 기본 제한 시간 |
queueMax | 32 | 귀를 대기 전에 온 이벤트를 이름마다 보관하는 최대 개수. 넘으면 가장 오래된 것을 버리며 경고합니다 |
onError | console.error | 이벤트 handler 가 던졌을 때 부릅니다 |
strict | 운영이 아니면 true | 보내는 메시지가 틀리면 던지나(개발) 경고하고 보내나(운영) |
기본으로 지키는 것
- 앱과의 통로는 이 패키지 하나입니다. 로그인도 같은 요청·응답·
callback전역을 씁니다. 플랫폼은 한 함수가 고릅니다 —platform()과 보내는 쪽이 같은 순서(Android, iOS,androidLegacy)로 보기 때문에 둘 다 있는 창에서도 «android» 라고 알리고 Android 로 보냅니다. - 이름은 플랫폼마다 하나입니다. 별칭과 «차례로 찔러 보기» 사슬이 쌓이면 어느 이름이 살아 있는지 아무도 모르게 됩니다. 옛 이름은
androidLegacy하나만 받습니다. - 앱인지·버전·기능은 UA 표식으로 판정합니다. 모든 요청에 실려 서버(SSR)와 브라우저가 같은 판정을 합니다. 페이지가 다 열린 뒤 넣는 전역으로 판정하면 마운트 시점에 아직 없어 «최신 앱으로 업데이트» 가 잘못 뜨고, 한 번 본 값을 localStorage 에 굳히면 브라우저에서도 «앱» 으로 남습니다.
- 모르는 동작을 조용히 버리지 않습니다. 앱은
reject(id, { error: "unsupported_action" })으로 답하고, 기능 목록을 아는 동안request(..., { capability })는 보내기 전에 막습니다. - 앱이 먼저 보낸 이벤트를 잃지 않습니다. 브릿지를 만들면 통로가 열리고, 귀를 대기 전에 온
emit은 보관했다가on이 붙을 때 줍니다. 보관함은 이름마다 따로라noise40 개가nav하나를 밀어내지 않고, 넘치면 조용히 버리지 않고 경고합니다. 중복 제거는이름:id로 하므로 다른 이름의 같은id는 서로 버리지 않고, 전달됐거나 보관된 이벤트의id만 «본 것» 으로 적습니다(콜드 스타트 딥링크가 사라지거나 두 번 열리지 않게). - handler 하나의 실패가 나머지를 막지 않습니다. 보관된 이벤트를 줄 때 handler 가 던져도
on은 끝까지 돌며 남은 이벤트를 전달하고, 항상 해제 함수를 돌려줍니다. 오류는onError(없으면console.error)로 갑니다. 해제 함수는 몇 번을 불러도 한 번만 듣기를 끝냅니다. - 같은
callback을 쓰는 브릿지는 통로 하나를 나눠 씁니다.eventPrefix가 달라도 되며, 앱의emit은 귀를 대고 있는 모든 접두의 구독자에게 갑니다.queueMax는 그 브릿지들 중 가장 큰 값이 쓰입니다. - 응답도 검증합니다(선택).
responses에 동작별 검증기를 주면 틀린 응답은invalid_response로 거절됩니다.request(..., { signal })로 취소하면 거절하고 대기 항목·타이머·리스너를 치웁니다. - 앱의 거절 코드는 문자열만 믿습니다.
reject(id, { error: <객체> })처럼 문자열이 아닌 코드는native_error로 바꾸고 받은 값은BridgeError.cause에 둡니다. - UA 표식과
supports/capabilities는 참고용입니다. 클라이언트가 정하는 값이라 아무나 바꿔 보낼 수 있습니다. «이 앱이면 UI 를 이렇게 보여 준다» 까지만 쓰고, 권한·결제·보안 판단의 근거로 삼지 마세요(서버는 따로 인증합니다). 표식은(ios;caps=a)처럼 붙여 쓰거나;앞뒤에 공백이 여럿이어도 읽습니다. - 버전 비교는 semver 입니다.
compareVersions는 앞의v와+빌드를 무시하고,1.0.0-beta.2→1.0.0-beta.10→1.0.0순으로 앞서 있다고 보며(로캘과 무관),-1·0·1을 돌려줍니다. - 운영 판정은
process.env.NODE_ENV리터럴로 합니다. 번들러가 치환하는 모양 그대로라strict기본값이 운영 빌드에서 올바르게 꺼집니다(process가 없는 환경은 «운영 아님» 입니다. 확실히 하려면strict를 직접 줍니다). - iOS 에는 객체, Android 에는 JSON 문자열을 보냅니다.
undefined필드는 뺍니다(WKScriptMessage 는 JSON 으로 만들 수 있는 값만 받습니다). - 메시지 모양을 검증합니다. 개발에서는 던지고, 운영에서는 경고하고 보냅니다(구 셸은 모르는 필드를 무시합니다). 받은 이벤트의 모양이 틀리면 버립니다.
- 토큰은 메시지 본문으로 주고받습니다. URL 쿼리에 싣지 않습니다(로그·Referer·기록에 남습니다).
id·envelopeKey는 예약 키라 payload 에 쓸 수 없습니다.- 메서드를 떼어 써도 됩니다(
const { post } = bridge).
그 밖
- 메시지를 늘릴 때는 필드를 더하기만 합니다. 지우거나 뜻을 바꾸면 앱과 웹이 동시에 배포되지 않는 동안 깨집니다 — 웹을 먼저 배포하고, 셸은 모르는 동작에
unsupported_action으로 답합니다. - Android 는
addJavascriptInterface보다WebViewCompat.addWebMessageListener를 권합니다. 허용 오리진과 메인 프레임에만 주입되어 광고·외부 iframe 이 브릿지를 부르지 못합니다(https://developer.android.com/reference/androidx/webkit/WebViewCompat, 2026-11-03 확인). - Capacitor 셸(태블릿)은
window.Capacitor.Plugins를 직접 씁니다. 이 패키지는 그 셸의 로그인 복귀(Custom Tab → 앱 스킴) 같은 일을 대신하지 않습니다.