npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lemoncloud/hybrid-shell

v0.5.0

Published

A virtual mobile shell for developing and testing hybrid (WebView) apps in a desktop browser.

Downloads

979

Readme

hybrid-shell

A virtual mobile shell for hybrid (WebView) apps. It runs your web app inside a phone frame in a desktop browser and plays the native side of the bridge, so you can develop and test the web half of a hybrid app without Xcode, the Android SDK, or a device.

Status: early development (0.1.0). Expect breaking changes before 1.0.

Derived from the dou-app virtual shell (apps/virtual-shell and libs/fake-shell, ADR 0208), copied without its git history.

What is here today

  • defineShell() and loadConfig() — the app config (hybrid-shell.config.ts), loaded with jiti so the config can be TypeScript with no build step.
  • A canonical vocabulary — 14 shell → web events and the web → shell requests (CANONICAL_EVENTS, CANONICAL_REQUESTS) — that the API, logs and scenarios use; each app maps it to its own names.
  • Protocol presets: native-host ({ kind, id, method, params } envelopes, __<APP>_* globals, <app>-native-message events) and app-bridge-host (typed messages with a WebAppReady handshake). A bridge of typed messages — { type, data } or flat { type, ... }, with an id or answered by type name (RequestDeviceInfo → DeviceInfo) — is described as data with typedMessage(spec), which returns a preset; see examples/typed-message. An app that fits none of them passes its own ProtocolPreset object. A web app with no native shell at all uses web: no globals, no bridge, only the events a browser carries — see the web preset.
  • createBridge() — answers the web's requests with your handler module, else the core default for the canonical request, else the preset's refusal (at once, so the web never waits on retries).
  • @lemoncloud/hybrid-shell/fake-shell — FakeShell, a Playwright helper that plays the native side in an E2E test on the same preset and bridge: install(page, { config }), emit(event, payload), handle(method, handler), received(), waitFor(), unhandled.
  • Environment simulation, installed in the frame before the web's first script: the network (offline, slow — a wait before the answer, or a body handed on at bytesPerSec or a 3g / 2g preset so a progress bar shows each step — flaky, URL rules; fetch, XHR, WebSocket and EventSource all see it, and every request lands in a net log line — a slowed one when its last byte arrives, so its ms is the whole download), the sockets on their own (net.ws.drop · stall (half-open: messages vanish, no close) · inject · lag · resume · list · messages, in the same rule list and cleared by net.clear(); each message kept by its size and, for text, its first 160 characters with the values under secret-named keys, JWTs and bearer tokens masked, in the socket's own ring of the last 200 apart from the log — never in an export or a recording —, with list() counting them all), app lifecycle (foreground, background, cold and warm restart, reload, crash, screen lock, low memory), seven device presets with rotation and safe areas — the iPhone Duo among them, closed, half-open or open (device.pose), with its sizes and insets measured on the iOS 27.1 simulator: unfolding turns the page (upright closed, on its side open), half-open folds it across its long side, the page is resized, never relaunched, and an app that maps the fold event hears { posture, segments } (the areas either side of the fold) and finds it in device.info; a keyboard the new screen has no room for, after a pose or a turn, is put away —, locale, time zone, theme, keyboard, geolocation, clipboard, permissions and the requests a person answers (alerts, share sheets, external links, permission prompts) as pending cards. Events sent before the web listens are held, so a cold start's push or link is the first thing it hears.
  • Situations (recipe.run(id)): seven named bundles of tools — opened from a notification while closed, subway (offline, back online with five seconds to reconnect, then the sockets alone cut, then a 2G trickle), English phone in dark mode, … — that a config extends with recipes. A situation that needs an event or request the app does not map is dimmed, with the missing name under it; it is never hidden. recipe.stop() ends the running one before its next step (its result is STOPPED; the phone stays as the steps so far left it) — on the page, pressing the running situation again.
  • The shell page, built as static files into dist/ui/: situations, the scenario panel and one fold of raw tools (the network among them, with a search box) on the left, the phone in the middle — under it the simulator's toolbar: Home (the app goes behind a home screen whose icon brings it back), Screenshot and Record (the browser captures the tab, after asking, cropped to the screen where it can, saved as PNG and WebM — MP4 where the browser records only that), Rotate, and for a phone that folds Closed, Half open and Open; a phone that folds draws its fold and hinge —, the state (with the open sockets, each with Drop and Stall), pending cards and a plain-words timeline on the right. The edges between them drag to resize (or ← → on a focused edge; Enter folds a side, a double click puts it back), and the widths are kept in that browser; the sides fold into drawers at 900px and under. A four-line getting-started card sits beside the phone when the centre has room, and is a one-line strip above it (its steps a press away) when not — it never covers the app — until it is closed once. A situation's outcome stays under its button until the next one; saving a recording says the file's name and ends it. The page holds what a person looks at and presses; what only an agent uses (YAML text, the log as a file, cards for unhandled requests) is the API's. Every button calls the same function an agent reaches through window.virtualShell.
  • One timeline of four sources — web (the page's console), the bridge (web→shell, shell→web, refused requests; filter them together as bridge), net, and agent: every tool call with who made it, situations between recipe:<id> and recipe:<id> done, virtualShell.note(text), and what a person does in the frame (taps with where the finger went down, typing once per field, Enter, submits) with the control's test id, role, name and text. A password, one-time-code or card field — and any field matching the config's redact.inputs selectors (passed to the page once the shell server lands, like recipes and devices) — is recorded as {{secret.<name>}}; its text is never read. virtualShell.logs.export({ format: 'json' | 'md', since }) gives it as text to attach to a report. On the page the timeline shows one kind at a time when asked (app — the bridge and the console —, network with the sockets' messages, tools and runs, performance, shell), folds the same line repeated into one with a count, folds a console line the app also forwards over its bridge (a webLog) into that line with a mark, and opens a line to the data it carries, to copy. Scrolled down to read, it holds still and counts the new lines above. A scenario's passing steps are in the scenario panel (and the tools view), not repeated in the others; a notification the app got while away is a shell line with its own Tap.
  • Made for an agent to read: every line has a level (logs({ level: 'warn' }) is warnings and errors); an error the page did not catch and a promise nobody handled are web errors with their stack; a request's line comes when its body ends, with ttfbMs, ms, bytes (null when nothing says) and cached; a bridge reply carries the latencyMs the app waited; a path change is a route line timed to the next paint. A perf source in its own ring of 500 (logs.perf(), off with shell.perf: false) has the load (FCP, LCP, DOMContentLoaded, load), long tasks, the heap every 10 seconds and dropped frames. virtualShell.mark(name) and span(name).end() write mark: and span: lines, and so does the app through window.__hybridShell. logs.summary({ since }) counts it all up — errors by message, the slowest requests and replies, refusals, load, long tasks, heap, routes, sockets — and logs.export({ format: 'md', budget }) puts what matters first and folds the rest into one line.
  • Scenarios (schema) — YAML files of steps: a tool call (push.tap: { … }), a situation (recipe: subway), a screen step (tap — at { x, y } or at a target's centre or a corner, as a finger: pointer, touch, then mouse events and the click, on whatever is at that point — click, type, press, scroll, submit, each finding its control by shown text, role + name, or testid; a target two controls match fails as ambiguous), wait, note, and eight checks (expect.event, .path, .text, .notText, .net — a request, or a socket's open · close · message · reopen — .hitArea — big enough to press and not covered — .status, .screenshot). virtualShell.scenario.run(yaml, { vars, secrets }) runs one in the page and answers { name, status, steps: [{ n, step, status, ms, detail }], log }; a scenario whose requires the app does not offer is skipped, and a prod shell refuses any scenario not marked prod: safe. {{secret.<key>}} is filled from secrets at the type step only; the result (its log included) and the runner's own lines carry the placeholder, never the value. The scenario panel asks for each secret an opened file needs, for that run only, and lists the config's scenarios folder: picking a file loads it. scenario.stop() stops the running one and scenario.last() is the last run; the scenario panel runs (and stops) a picked or opened one and shows every run step by step, and a failed step opens its line in the timeline.
  • Recording: virtualShell.logs.toScenario({ since, name, state }) turns a stretch of the log into a scenario draft — one step per tool call, a situation as one step, a person's input as screen steps — a tap by the first selector that matched only that control, with # observed: tap at x,y, or by its point when none did — what the app did as # observed: comments (never expect: a recording of a bug does not know the right answer), repeated ids as vars, and a state block with the phone it started from. The timeline's "Start recording" and "Save as scenario file" download the same text as <name>.scenario.yaml; "Open a scenario file" in the scenario panel makes its state and runs it.

examples/ has a fake web per preset with an integration test each — globals, one request, one event, storage restored after a relaunch — and one scenario each in the virtual shell: offline → fail → recover, background → cold push tap → the linked screen, camera denied → app settings. typed-message has two webs, one per envelope shape; the flat one only listens. Three also have a YAML scenario under scenarios/ — two pass, and typed-message/data's fails on purpose on an ambiguous target; its socket-reconnect drops the web's socket and waits for it to come back. examples/csp is a web served with a Content Security Policy, which forbids the frame's own eval the runtime is installed with; the shell server's proxy strips it. examples/web is a web app with no native shell, once on Vite and once on Angular's ng serve, with the shared deep-link, offline-feed, push-opens-screen and tap-hit-area scenarios.

Running it

Try it with no install, against a web dev server that is already running:

npx @lemoncloud/hybrid-shell --web http://localhost:4700 --app myapp

It prints the shell's address (the web's port + 4, e.g. http://localhost:4704/__shell/). --app is the name the web looks for in its globals and event names; --preset defaults to native-host, whose starter method and event names are assumed. --handlers <file> answers app-only methods and --scenarios <dir> is what scenario.list() shows. A web app with no native shell needs no --app: --preset web (the web preset). The panel's "Save as config file" (or hybrid-shell init --web … --app …) writes hybrid-shell.config.ts from those flags, never over an existing file.

Or let it read the app's repository and draft the config:

npx @lemoncloud/hybrid-shell init --scan --dry-run   # what it found, step by step; writes nothing
npx @lemoncloud/hybrid-shell init --scan             # hybrid-shell.config.ts + apps/shell-adapter/src/handlers.ts

It reads source files only: the envelope the web posts (ReactNativeWebView.postMessage(…)) picks the preset — with no ReactNativeWebView, onMessage or injectedJavaScript anywhere, web — a typed one gets a typedMessage({ … }) draft with its channel, globals and Request<X> → <X> pairing — then the globals the native app sets, the names the web listens for and sends (its bridge type tables included, the native message handler's too), the canonical mapping, and the web's port and start command. Env files are listed by name and never opened. A canonical name matched by a word rather than the starter's own name is marked // AMBIGUOUS:, what it could not find // TODO:; yarn local --check counts what is left. A canonical name it left out is not offered, and the panel dims the situations that need it, saying which name is missing. For a typed bridge it reads the native handler's case 'X': blocks for the type each request is answered with (and failed with), and the web's { type, data } union for that answer's shape: each app-only request gets a stub answer of that shape, marked // TODO:. A Google sign-in request is drafted as auth: oauth-popup, another provider's as 'unsupported'. The handler module lists the app's remaining names for the person writing their answers; sampleImage() is a picture to answer a camera or photo picker with, once the handler has asked shell.permissions.request(kind) as the app does.

Installed, with a config file:

yarn add -D @lemoncloud/hybrid-shell   # "local": "hybrid-shell local" in package.json scripts
yarn local --check                     # env files per env.source, free ports, an installed core behind npm — reports and exits
yarn local [--env prod]                # starts web.start, serves the shell, stops both on Ctrl-C
yarn local --mode web                  # the web dev server alone, on the dev backend
yarn local --mode native --platform android   # the config's native.android command

The shell's DEV | PROD switch runs the other backend's web and shell on their own ports (prod: the web's port + 10 and + 14) in the same run, so a session never crosses backends; Ctrl-C stops both. Prod runs in the shell only, where the page shows which backend it is.

A taken port stops the run with its name; the shell never moves to another. Env files are put in place per env.source (example copies .env.example and names keys left empty, secrets-repo clones the repository into the user's cache and copies the mapped files, manual says what is missing); no value is ever printed.

Scenarios from a terminal or CI (needs @playwright/test as a devDependency, plus npx playwright install chromium):

yarn hybrid-shell run scenarios/*.yaml [--headed] [--out dir] [--var key=value] [--secret key=ENV_NAME]

It reuses a shell already running for the same env or starts one for the run, runs each file in headless Chromium as a touch phone (hasTouch, isMobile — a web that checks ontouchstart takes its mobile path; a tap presses the same way as from the panel), writes screenshots and results.json to --out (default hybrid-shell-results), prints the results, and exits 1 if a scenario failed or none ran.

Docs

App config

// hybrid-shell.config.ts
import { defineShell } from '@lemoncloud/hybrid-shell';
import type { NativeEvents, NativeMethods } from './src/native-types';

export default defineShell<NativeMethods, NativeEvents>({
    app: { name: 'myapp', displayName: 'My App', scheme: 'myapp' },
    preset: 'native-host',
    web: { start: 'yarn dev', port: 4700 },
    env: { files: ['.env'], source: 'example' },
    events: { 'app.state': { name: 'appStateChanged' } },
    requests: { 'device.info': 'getDeviceInfo' },
    quickLinks: { origin: 'https://app.example.com', screens: [{ id: 'home', path: '/' }] },
});

requests values must be names in your method table and events[*].name names in your event table, so renaming a bridge method breaks the config at compile time. Fake answers for your own methods go in a handler module (handlers: () => import('./hybrid-shell.handlers')), each checked against that method's params and result types; 'unsupported' answers with the preset's refusal.

A method no handler answers is refused at once too, with an unanswered line in the bridge log and a notice on the panel; shell.unanswered: 'ask' puts it on a card to answer by hand instead (5 seconds, never during a scenario run, never for payments, SMS, stores or push tokens, nor the names in shell.neverAsk). hybrid-shell local --check counts the requests the web sends that nothing answers. The shell page opens in the browser's language (Korean for a Korean browser, English otherwise) and light; shell.ui: { locale: 'ko' | 'en', theme: 'light' | 'dark' | 'system' } sets other defaults, and a pick in the header's Page menu (the page's own language and colours, apart from the phone's locale and theme) is kept in that browser.

Development

yarn install
yarn test        # vitest
yarn typecheck
yarn build       # library (dist/*.js, bundled .d.ts) + shell UI (dist/ui)
yarn verify      # all of the above + a NodeNext consumer type check + the packed package (test:pack)

License

Apache-2.0