본문으로 건너뛰기
언어

@plott/webkit-native0.4.0

앱 WebView 안의 웹이 쓰는 네이티브 기능 — 한 이름의 전송(iOS WKScriptMessageHandler·Android WebMessageListener/JavascriptInterface), 요청·응답(id·제한 시간·미지원 동작), 네이티브 이벤트(리스너가 붙기 전 이벤트 보관·중복 제거), UA 표식으로 앱·버전·기능 판정(서버와 브라우저가 같은 판정). 검증기는 Standard Schema(zod 등)로 받는다. 의존성 없음

설치

pnpm
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-nativecreateBridge, BridgeError, parseAppUserAgent·buildAppUserAgent·isAppUserAgent·compareVersions, asAuthBridge
@plott/webkit-native/reactuseNativeEvent

붙이기

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) 입니다.

ts
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 는 기능이 있는 앱인지 알려 줍니다.

ts
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 로 같은 판정을 합니다.

ts
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 }) 로 바꿉니다.

ts
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")
envelopeKeyaction동작 이름을 싣는 키({ action: "share", url }). type 을 쓰는 앱이면 바꿉니다
eventPrefixnative:앱 이벤트의 CustomEvent 이름 접두
userAgentToken없음UA 표식 토큰. 없으면 UA 로 판정하지 않습니다
contextGlobal없음앱이 문서 시작 때 넣는 문맥(안전 영역·기능·버전) 전역
messages·events·responses없음동작별 보내는 메시지·받는 이벤트·request 응답 검증기
timeoutMs30초request 의 기본 제한 시간
queueMax32귀를 대기 전에 온 이벤트를 이름마다 보관하는 최대 개수. 넘으면 가장 오래된 것을 버리며 경고합니다
onErrorconsole.error이벤트 handler 가 던졌을 때 부릅니다
strict운영이 아니면 true보내는 메시지가 틀리면 던지나(개발) 경고하고 보내나(운영)

기본으로 지키는 것

  • 앱과의 통로는 이 패키지 하나입니다. 로그인도 같은 요청·응답·callback 전역을 씁니다. 플랫폼은 한 함수가 고릅니다 — platform() 과 보내는 쪽이 같은 순서(Android, iOS, androidLegacy)로 보기 때문에 둘 다 있는 창에서도 «android» 라고 알리고 Android 로 보냅니다.
  • 이름은 플랫폼마다 하나입니다. 별칭과 «차례로 찔러 보기» 사슬이 쌓이면 어느 이름이 살아 있는지 아무도 모르게 됩니다. 옛 이름은 androidLegacy 하나만 받습니다.
  • 앱인지·버전·기능은 UA 표식으로 판정합니다. 모든 요청에 실려 서버(SSR)와 브라우저가 같은 판정을 합니다. 페이지가 다 열린 뒤 넣는 전역으로 판정하면 마운트 시점에 아직 없어 «최신 앱으로 업데이트» 가 잘못 뜨고, 한 번 본 값을 localStorage 에 굳히면 브라우저에서도 «앱» 으로 남습니다.
  • 모르는 동작을 조용히 버리지 않습니다. 앱은 reject(id, { error: "unsupported_action" }) 으로 답하고, 기능 목록을 아는 동안 request(..., { capability }) 는 보내기 전에 막습니다.
  • 앱이 먼저 보낸 이벤트를 잃지 않습니다. 브릿지를 만들면 통로가 열리고, 귀를 대기 전에 온 emit 은 보관했다가 on 이 붙을 때 줍니다. 보관함은 이름마다 따로라 noise 40 개가 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 → 앱 스킴) 같은 일을 대신하지 않습니다.