diff --git a/docs/en/apis/index.mdx b/docs/en/apis/index.mdx index e551bcdb..59620a2e 100644 --- a/docs/en/apis/index.mdx +++ b/docs/en/apis/index.mdx @@ -10,13 +10,13 @@ APIs for Lynx bundles running inside Sparkling containers. -| API | Description | -| --- | --- | -| [GlobalProps](/apis/global-props/Interface.GlobalProps) | Runtime globals injected by the native SDK (`lynx.__globalProps`) | -| [Scheme](/apis/scheme) | The unified `hybrid://...` URL format for opening pages/containers | -| [Navigation](/apis/sparkling-methods/sparkling-navigation) | Router helpers for opening/closing pages from Lynx/JS | -| [Storage](/apis/sparkling-methods/sparkling-storage) | Key-value storage helpers for Lynx/JS | -| [Media](/apis/sparkling-methods/sparkling-media) | Media helpers for choosing, uploading, downloading, and saving files | +| API | Description | Web | +| --- | --- | --- | +| [GlobalProps](/apis/global-props/Interface.GlobalProps) | Runtime globals injected by the native SDK (`lynx.__globalProps`) | - | +| [Scheme](/apis/scheme) | The unified `hybrid://...` URL format for opening pages/containers | - | +| [Navigation](/apis/sparkling-methods/sparkling-navigation) | Router helpers for opening/closing pages from Lynx/JS | Yes | +| [Storage](/apis/sparkling-methods/sparkling-storage) | Key-value storage helpers for Lynx/JS | Yes | +| [Media](/apis/sparkling-methods/sparkling-media) | Media helpers for choosing, uploading, downloading, and saving files | Yes | ## Sparkling SDK diff --git a/docs/en/guide/cli.md b/docs/en/guide/cli.md index 11d815ab..145bb681 100644 --- a/docs/en/guide/cli.md +++ b/docs/en/guide/cli.md @@ -25,11 +25,14 @@ npx sparkling build | Option | Description | | --- | --- | | `--config ` | Path to `app.config.ts` (default: `app.config.ts`) | +| `--platform ` | Target platform: `android`, `ios`, `web`, or `all` (default: `all`) | | `--copy` | Copy built assets to Android and iOS native shells | | `--skip-copy` | Skip copying assets (default) | By default, asset copying is skipped for faster iteration during development. Use `--copy` when you need the bundles inside the native projects (e.g. for a release build). +When `--platform web` is specified, only web bundles (`*.web.bundle`) are produced. This is faster than building all environments. + ### `sparkling dev` Start the Rspeedy dev server for hot-reload development. Instead of rebuilding and copying bundles manually, the dev server serves bundles over HTTP so changes are reflected instantly. @@ -41,6 +44,7 @@ npx sparkling dev | Option | Description | | --- | --- | | `--config ` | Path to `app.config.ts` (default: `app.config.ts`) | +| `--platform ` | Target platform: `web`, `native`, or `all` (default: `all`) | | `--port ` | Dev server port (default: `app.config.ts -> dev.server.port`, fallback `5969`) | | `--host ` | Dev server host (default: `app.config.ts -> dev.server.host`, then Rspeedy default) | @@ -81,12 +85,13 @@ npx sparkling autolink | Option | Description | | --- | --- | -| `--platform ` | Platform to autolink: `android`, `ios`, or `all` (default: `all`) | +| `--platform ` | Platform to autolink: `android`, `ios`, `web`, or `all` (default: `all`) | **What it does:** - **Android** — Links Sparkling method Gradle projects and generates `SparklingAutolink.kt`. Debug-tool packages are linked as `debugImplementation`. - **iOS** — Links Sparkling method pods and generates `SparklingAutolink.swift`. Debug-tool packages are linked in the debug target. +- **Web** — Reads `web` entries (or `platforms` containing `web`) from each `module.config.json` and generates a `web-autolink.ts` file that imports all `*/web` method handlers. ### `sparkling run:android` @@ -140,6 +145,24 @@ This command will: You can also set the `SPARKLING_IOS_SIMULATOR` environment variable to specify a default simulator. +### `sparkling run:web` + +Build web bundles and launch a browser preview. + +```bash +npx sparkling run:web +``` + +This command will: + +1. Build web bundles (`*.web.bundle`) +2. Start the `sparkling-web-shell` dev server +3. Open the app in your default browser at `http://localhost:3000` + +Use `?page=` query parameters to navigate to different entry points (e.g. `http://localhost:3000?page=second`). + +For more details, see the [Web Platform Guide](/guide/web-platform). + ### `sparkling doctor` Verify that your development environment is properly set up. @@ -196,6 +219,9 @@ npx sparkling run:android # 5. Run on iOS npx sparkling run:ios -# 6. Build bundles for release +# 6. Preview in browser +npx sparkling run:web + +# 7. Build bundles for release npx sparkling build --copy ``` diff --git a/docs/en/guide/get-started/create-custom-method.mdx b/docs/en/guide/get-started/create-custom-method.mdx index c10e0458..b013a4bd 100644 --- a/docs/en/guide/get-started/create-custom-method.mdx +++ b/docs/en/guide/get-started/create-custom-method.mdx @@ -256,6 +256,17 @@ npx sparkling autolink +## Add web support (optional) + +You can add a web implementation so your method works in the browser. See the full guide at [Web Method Implementations](/guide/web-method-implementations). + +In brief: + +1. Create `src/web/index.ts` with `registerWebMethod()` calls +2. Add `"./web"` subpath export to `package.json` +3. Add `"web"` to `platforms` in `module.config.json` +4. Run `npx sparkling autolink` + ## Best practices - **Naming convention**: package name should follow `sparkling-` format. diff --git a/docs/en/guide/get-started/create-new-app.mdx b/docs/en/guide/get-started/create-new-app.mdx index a8122fe9..a316fd6c 100644 --- a/docs/en/guide/get-started/create-new-app.mdx +++ b/docs/en/guide/get-started/create-new-app.mdx @@ -18,7 +18,7 @@ npm create sparkling-app@latest my-app cd my-app ``` -### Run native targets +### Run on any platform ```bash # Android @@ -26,6 +26,9 @@ npm run run:android # iOS npm run run:ios + +# Web (browser preview) +npm run run:web ``` ### Add Sparkling methods as needed @@ -56,7 +59,7 @@ Key folders/files created by the default template: - `src/`: Lynx/React entry points and assets - `android/`, `ios/`: native shells wired to Sparkling SDK - `app.config.ts`: build + routing config consumed by `sparkling-app-cli` -- `package.json`: scripts (`dev`, `build`, `run:android`, `run:ios`) +- `package.json`: scripts (`dev`, `build`, `run:android`, `run:ios`, `run:web`) ### Prerequisites diff --git a/docs/en/guide/nuxt-sparkling-compat.md b/docs/en/guide/nuxt-sparkling-compat.md new file mode 100644 index 00000000..13417901 --- /dev/null +++ b/docs/en/guide/nuxt-sparkling-compat.md @@ -0,0 +1,115 @@ +# Nuxt on Sparkling: routing feature compatibility + +This is the acceptance traversal for driving Sparkling native navigation +from Nuxt's file-based routing. Every Nuxt routing feature is walked once +and classified: + +- ✅ **Supported** — works, with a ported test proving it. +- ⚠️ **Degraded** — works with a caveat inherent to the MPA model; the + build emits a diagnostic. +- ❌ **Unsupported** — cannot work under MPA; the reason is structural. + +The classification is enforced in code: `pagesToSparklingManifest()` +returns a `diagnostics[]` list, and the Nuxt module prints a per-app +support report at build time (`analyzePages()`). + +## The one constraint everything follows from + +In VueLynx's SPA model, one LynxView holds one JS heap and Vue Router +navigates in memory. Here we drive **Sparkling native navigation**: each +page is a separate LynxView with a **separate JS heap**. There is no shared +memory across pages, so: + +> A URL cannot be resolved by an in-memory route table that another page +> holds. It must be resolved from **build-time metadata** (the route +> manifest) embedded in every bundle. + +Every "degraded" or "unsupported" verdict below traces back to this: state, +component instances, and outlets **cannot cross a JS heap**. Navigation +(opening another page) always can. + +## Verification method + +1. **Ported Nuxt fixtures.** Nuxt's official `packages/nuxt/test/pages.test.ts` + (v4.4.8) `pageTests` are the ground truth for file → route generation. + The exact `NuxtPage[]` trees Nuxt produces are fed through our + `pagesToSparklingManifest()` and asserted + (`packages/nuxt-sparkling/__tests__/nuxt-pages.spec.ts`, 12 cases + spanning every routing pattern). +2. **Runtime matching.** The generated manifest is matched against real + URLs via `matchSparklingRoute()` to prove params extract correctly. +3. **End-to-end.** Real Vue Router runs over `web-navigation-shim` + the + Sparkling host over the real `NativeModules.spkPipe` bridge + (`integration.spec.ts`): deep-link boot, cross-bundle `router.open` + handoff, native `router.close`, external `webview` handoff. + +## Feature-by-feature + +### Route generation (file → route) + +| Feature | Verdict | Evidence / notes | +|---|---|---| +| `index.vue` → `/`, nested `parent/index.vue` → `/parent` | ✅ | flattened to absolute paths | +| Static routes (`about.vue`, `snake_case`, `kebab-case`) | ✅ | one bundle per route | +| Dynamic `[id]` → `:id()` | ✅ | whole-segment param, matches at runtime | +| Regex param `[id(\d+)]` | ✅ | custom regex preserved in manifest & matcher | +| Catch-all `[...slug]` → `:slug(.*)*` | ✅ | matches `/a/b/c` → `slug: ['a','b','c']` | +| Catch-all in middle (`[...id]/suffix`) | ✅ | matches `/a/b/suffix` → `id: ['a','b']`; the matcher splits segments paren-aware so the slash inside the param regex is respected | +| Optional `[[id]]` → `:id?` | ✅ | matches present & absent | +| Nested dynamic (`[bar]/index`, `nonopt/[slug]`) | ✅ | absolute-path flattening | +| Route groups `(foo)/…` | ✅ | folder dropped from URL; `groups` carried in `meta` | +| Unicode / encoded paths (`测试.vue`) | ✅ | Nuxt-encoded paths passed through and matched | +| Colliding names, hyphen edge cases | ✅ | Nuxt's `name` reused as the unique manifest entry | +| Mixed literal+param in one segment (`prefix-[[opt]]`, `route-[slug]`, `[[opt]]-postfix`, `[b2]_[2b]`) | ⚠️ | Native open works; the runtime matcher only matches whole-segment params, so in-page matching of this route is best-effort. Diagnostic: `mixed-segment`. | +| Malformed (`[slug.vue`, `[].vue`) | ✅ (as error) | Nuxt throws during scan before we see it; parity preserved | + +### Navigation at runtime + +| Feature | Verdict | Notes | +|---|---|---| +| `navigateTo('/other')` across bundles | ✅ | becomes `router.open(hybrid://…?bundle=other.lynx.bundle&__path=/other)` | +| `` | ✅ | RouterLink → `router.push` → shim `location.assign` → native open | +| `router.push` / `replace` across bundles | ✅ | replace maps to `router.open` with `replace: true` | +| Deep-link / initial route | ✅ | page boots at its URL, reconstructed from the `__path` container query item | +| External URL (`navigateTo(url, { external })`, `` off-origin) | ✅ | routed through the manifest's `externalScheme` (webview container) | +| `window.open(url)` | ✅ | host `open(url, { newWindow })` → webview | +| Hardware / UI back at page root | ✅ | `history.back()` at the bottom of the local stack → `router.close` (native pop); previous page's heap resumes with state intact | +| In-page back/forward, hash nav (within one bundle) | ✅ | full History API when `mode: 'web'`; `popstate` semantics verified against real Vue Router | +| `definePageMeta({ middleware })` (route middleware) | ✅ | runs in the target page's heap after boot; global middleware must be duplicated per page (build concern) | +| Static/string `redirect` in `definePageMeta` | ⚠️ | encoded into manifest `meta.redirect`; a host can resolve it before open. Diagnostic: `redirect`. | +| Function `redirect` | ⚠️ | only evaluable once the target page's JS runs; redirect happens after boot, not before the native open | + +### Layout / outlet features + +| Feature | Verdict | Notes | +|---|---|---| +| Single leaf page per URL | ✅ | the common case | +| Nested route with parent `` outlet (`parent.vue` + `parent/child.vue`) | ⚠️ | Child navigates as its own native page. The parent wrapper **is not kept mounted** across children (separate heaps), so a persistent parent layout/state is lost. Fix: duplicate shared layout into each child bundle (build), or express the wrapper as a native container. Diagnostic: `nested-outlet`. | +| `` used purely as an index outlet (`page1.vue` + `page1/index.vue`) | ✅ | the index child owns `/page1`; the wrapper collapses cleanly | +| Named views (``) | ❌ | MPA has one native page = one outlet per URL. Multiple simultaneous named views cannot be rendered across heaps. Restructure as separate routes/containers. | +| `` (app-level layouts) | ⚠️ | works within a single page's render, but a layout is not a *shared live instance* across pages — each page re-instantiates it. Fine for presentation, not for cross-page live state. | +| Client-only in-place transitions between routes (`` transitions) | ❌ across pages | cross-page transitions are native (Sparkling animation), not Vue ``; in-page transitions still work | + +### State / data (navigation-adjacent) + +| Feature | Verdict | Notes | +|---|---|---| +| `useState` shared across pages | ❌ | separate heaps; use `sparkling-storage` or pass via URL/scheme params. See the further-portability doc. | +| Passing data between pages | ✅ | via route params / query (carried in `__path`) or `sparkling-storage` | +| `useRoute()` / `useRouter()` within a page | ✅ | standard Vue Router composables over the shim | +| `useRequestURL()` | ✅ | reads `location.href` from the shim | + +## Summary + +Nuxt's **routing surface** — file-based routes (all naming conventions), +dynamic/catch-all/optional params, route groups, `navigateTo`/``/ +programmatic navigation, deep links, external links, and hardware back — is +**fully supported** on Sparkling native navigation through the shim + route +manifest. + +The features that **degrade or are unsupported** all reduce to one cause: +constructs that require a **single shared JS heap** — persistent nested +outlets, named views, cross-page `useState`, in-place cross-page +transitions. These are exactly the SPA assumptions the MPA model trades +away for real multi-page native navigation, and the build reports each one +so authors can restructure or accept the caveat deliberately. diff --git a/docs/en/guide/nuxt-sparkling-further-portability.md b/docs/en/guide/nuxt-sparkling-further-portability.md new file mode 100644 index 00000000..ece90344 --- /dev/null +++ b/docs/en/guide/nuxt-sparkling-further-portability.md @@ -0,0 +1,80 @@ +# Beyond routing: other Nuxt subsystems on Lynx + Sparkling + +The navigation work (shim + manifest + module) makes Nuxt's **routing** +run on Sparkling native navigation. This document surveys the *rest* of +Nuxt and classifies each subsystem for portability into Lynx + Sparkling, +so follow-up work can be prioritized. + +Classification: + +- **A — Portable as-is**: pure build-time or pure-JS; nothing DOM/server. +- **B — Portable behind a shim**: needs a Web/host API the shim or a + Sparkling method can provide. +- **C — Remap to native**: the concept exists but must be backed by a + native capability (container config, storage) instead of the DOM. +- **D — Not applicable**: SSR/server-render/HTML-head concepts with no + device analogue (often better kept as a real backend the app calls). + +The recurring constraint is the same as for routing: **each Sparkling page +is an isolated JS heap**, so anything Nuxt shares "across the app" on the +client (state, a live layout instance, the payload) does not automatically +span pages. + +## A — Portable as-is + +| Subsystem | Notes | +|---|---| +| **Auto-imports** (`unimport`) | Build-time transform; produces plain imports. Works unchanged in the rspeedy/Lynx build. | +| **`useRuntimeConfig`** | Values injected at build; a plain object at runtime. Portable. | +| **`definePageMeta` / route meta** | Extracted at build; already carried into the Sparkling manifest `meta`. Portable. | +| **`useRoute` / `useRouter` / `navigateTo`** | Vue Router composables over the shim. Covered by the navigation work. | +| **Pure composables / utils** (`useId`, `useState` *within a page*, app config) | Plain reactive JS; no DOM. Portable. | +| **``** | On Lynx everything is the "client" — becomes a transparent passthrough (the server branch never runs). | + +## B — Portable behind a shim + +| Subsystem | What it needs | Path | +|---|---|---| +| **`$fetch` / `useFetch` / `useAsyncData`** | a global `fetch` | Lynx 3.x exposes a `fetch`; where absent, shim `fetch` over a Sparkling network method. The composable *caching/dedupe* layer is pure JS and portable. SSR **payload extraction** does not apply (no server render) — data is always fetched on device. | +| **Route middleware** (`defineNuxtRouteMiddleware`, `definePageMeta({ middleware })`) | vue-router guards | Runs inside the target page's heap after boot. Portable per-page. **Global** middleware must be duplicated into each bundle by the build (no shared app instance to register it once). | +| **Nuxt plugins** (`defineNuxtPlugin`) | app-init hook | Portable if the plugin is DOM/server-free. Each page runs its own plugin set (no shared app), so "install once" plugins become "install per page". | +| **`useError` / `error.vue`** | an error route | Portable as a normal page; `showError`/`clearError` work in-heap. Cross-page error propagation is native (open an error page). | +| **`useNuxtApp`** | the app context object | Portable; scoped to the page's heap. | + +## C — Remap to native + +| Subsystem | DOM behavior | Native remap | +|---|---|---| +| **`useHead` / `useSeoMeta`** | mutates `document.head` (title, meta, links) | No HTML head on device. Map the meaningful subset to the **container scheme**: `title` → nav-bar title, theme color → `nav_bar_color`, etc. (`sparkling-navigation` already passes these as scheme params). The rest (SEO meta, ``) is inert on device. | +| **`useState` across pages** | one heap in SSR/CSR | Within a page: a plain `ref`, portable. Across pages: no shared heap — bridge through **`sparkling-storage`** (persist on navigate, rehydrate on boot) or pass via route params in the manifest `__path`. Highest-value follow-up: a `useSparklingState` that transparently persists. | +| **`useCookie`** | `document.cookie` | Map to `sparkling-storage` (or a cookie-jar method) keyed like cookies. | +| **App layouts / ``** | one live instance wrapping pages | Rendered per page (presentation portable via VueLynx), but **not a shared live instance**. Shared layout state must go through storage or a native container; a persistent chrome (tab bar, nav bar) is better expressed as a **native Sparkling container** hosting the LynxViews. | +| **Page transitions** | Vue `` on the outlet | Cross-page transitions are **native** Sparkling animations (scheme/animated option). In-page transitions still use Vue. | + +## D — Not applicable (keep as backend, or no device analogue) + +| Subsystem | Why | Recommendation | +|---|---|---| +| **Nitro server routes** (`server/api`, `server/routes`) | a Node/edge server runtime | Don't port to device. Run Nitro as the app's **real backend**; the device calls it via `$fetch`. This is the natural split. | +| **SSR / payload / hydration** (`useHydration`, ``, island/server components) | server-render then hydrate HTML | No HTML render on device. Not applicable; data is fetched on device (category B). | +| **`useRequestHeaders` / `useRequestEvent` / `useRequestURL` (server branch)** | the server request | Server-only. `useRequestURL` *client* branch works over the shim (category A/B). | +| **`preloadComponents` / chunk prefetch / `emitRouteChunkError`** | HTTP code-splitting & chunk reload | Bundles are native assets, not HTTP chunks. Disabled by the module; native bundle preloading is a Sparkling concern, not Nuxt's. | +| **`` / image CDN**, DevTools, Nuxt UI DOM widgets | browser DOM / build integrations | Rendering is VueLynx's domain, not this navigation layer. Out of scope here. | + +## Suggested follow-up order + +1. **`useSparklingState`** — the biggest ergonomic win: `useState`-shaped + API that persists to `sparkling-storage` on navigate and rehydrates on + boot, giving the *feel* of shared state across the MPA. +2. **`$fetch` shim** — confirm/adopt Lynx's `fetch`; wire `useFetch`/ + `useAsyncData` (pure caching layer is already portable). Unlocks most + real apps. +3. **`useHead` → container scheme** — map title/theme to nav-bar/container + params so `useHead({ title })` "just works" as native chrome. +4. **Global middleware & plugin duplication** — a build step that injects + app-level middleware/plugins into each page bundle so "register once" + semantics hold across the MPA. + +None of these are blocked by the navigation architecture; each is an +additive shim or build step on top of the same layering +(framework → shim → `NavigationHost` → Sparkling). diff --git a/docs/en/guide/nuxt-web-api-dependencies.md b/docs/en/guide/nuxt-web-api-dependencies.md new file mode 100644 index 00000000..f434b28a --- /dev/null +++ b/docs/en/guide/nuxt-web-api-dependencies.md @@ -0,0 +1,126 @@ +# Nuxt's Web navigation/history API dependencies + +This is the reference inventory behind [`web-navigation-shim`](https://github.com/tiktok/sparkling/tree/main/packages/web-navigation-shim): +every Web DOM navigation/history API that Nuxt 4 (`nuxt@4.4.8`) and Vue +Router 5 (`vue-router@5.1.0`) rely on for **client-side routing**, and the +exact semantics each caller needs. It exists so a non-browser JS context +(Lynx) can provide these APIs faithfully. + +> Scope: navigation / history / URL only. Rendering (Vue → DOM) is out of +> scope — that is VueLynx's concern. + +## The boot gate + +Vue Router's entire client behavior is gated on one check: + +```js +const isBrowser = typeof document !== 'undefined' +``` + +If `document` is undefined, `router.install()` never performs the +install-time initial navigation, so `router.isReady()` never resolves and +the app never mounts. **The shim must define a `document` global** (any +object satisfies the gate). + +## API families + +### 1. `history.pushState` / `replaceState` / `state` / `length` / `go` + +Critical — this *is* client-side navigation under `createWebHistory`. + +| API | Required semantics | +|---|---| +| `history.state` (read) | Synchronously returns the exact (JSON-safe) object last stored for the current entry; updated by push/replace and by traversal *before* `popstate`. Initial `null` is fine. | +| `pushState(state, '', url)` | Synchronous; after it, `history.state === state` and `location` reflects `url`; truncates forward entries; **fires no event**. Accepts absolute same-origin **and** path-only URLs. | +| `replaceState(state, '', url)` | Same, in place; no length change; no event. | +| Two-write push protocol | Every `router.push` does `replaceState(current + {forward, scroll})` then `pushState(new)`. Both must apply synchronously in order. | +| `go(delta)` | May be async, but must fire **exactly one** `popstate` per call, with `location`/`state` already at the destination; out-of-range past the top is a no-op. | +| `history.length` | Number ≥ 1, grows on push (seeds an internal position counter). | +| `scrollRestoration` | Feature-tested with `in`; optional. | + +### 2. `popstate` / `pagehide` / `visibilitychange` + +- `window.addEventListener('popstate', …)` — **critical** for back/forward + under web history. Handler reads `event.state`; `location` must already + reflect the destination when it fires. +- `pagehide` (window) + `visibilitychange` (document) — **edge**: only used + to persist scroll on unload. Registration must not throw; firing is + optional. +- Not used at all: `beforeunload`, `pageshow`, `hashchange`. + +### 3. `window.location` + +- Under web history (critical): `protocol`, `host`, `pathname`, `search`, + `hash` (read at creation and every popstate); `assign`/`replace` + (pushState-failure fallback). +- In Nuxt boot (critical even under memory history): + `window.location.pathname/search/hash` read once for `initialURL` + (`pages/runtime/plugins/router.js`); `location.href` for + `useRequestURL()`. +- External navigation (the natural native-host hook): `navigateTo(to, { + external })` uses `location.replace(to)` / `location.href = to`; + route-rule redirects set `window.location.href`. + +### 4. `URL` (not `URLSearchParams`) + +Vue Router uses its own string parser — no native `URL`. **Nuxt requires +the native `URL` constructor** (relative-base resolution + component +getters) in `navigateTo`, `useRequestURL`, ``, payload plugin. +`URLSearchParams` is never used. + +### 5. `document` + +- `typeof document` — the boot gate. +- `document.querySelector('base')` — only when no base is passed; **Nuxt + always passes `app.baseURL`**, so this is off the Nuxt path. + `document.baseURI` is never used. +- `document.visibilityState` — inside the scroll-save handler only (edge). + +### 6. Anchor interception (`` / `RouterLink`) + +`guardEvent(e)` reads `e.metaKey/altKey/ctrlKey/shiftKey`, +`e.defaultPrevented`, `e.button`, `e.currentTarget.getAttribute('target')`, +then `e.preventDefault()`. All reads are undefined-safe; `navigate()` works +with no argument. External links / `target` set render a plain `` with +no interception. Visibility prefetch (default on) uses +`IntersectionObserver`, `requestIdleCallback` (has a `setTimeout` +fallback), and a `navigator` object — disable prefetch or stub these. + +### 7. `window.open` + +Only `navigateTo(to, { open })` (opt-in). Edge. + +### 8. `sessionStorage` / scroll + +Vue Router keeps in-session scroll in an in-memory `Map`, not +`sessionStorage`. `window.scrollX/scrollY` are read on push and on pop +(numbers required); `window.scrollTo` only if `scrollBehavior` returns a +position. **Recommendation: `scrollBehavior: () => false`** so the DOM +scroll path is never taken. `sessionStorage` is only used by chunk-reload +machinery (all try/catch-wrapped) — disable `emitRouteChunkError`. + +## Minimum viable NavigationHost contract + +Under `createWebHistory` semantics, the host must back a shim that provides: + +1. `document` defined (boot gate) + no-op `addEventListener`. +2. `location` with all WHATWG components, `assign`/`replace`/`reload`, + updated synchronously before `popstate`. +3. `history` with `state` round-trip, synchronous event-free + `pushState`/`replaceState` (absolute & path-only URLs), `go()` firing + exactly one `popstate` (out-of-range top = no-op), `length`, optional + `scrollRestoration`. +4. `window`/`document` `popstate`/`pagehide`/`visibilitychange` listeners. +5. `window.scrollX/scrollY` numbers + no-op `scrollTo`. +6. Native `URL` constructor. +7. For stock Nuxt defaults: `requestAnimationFrame` (or disable + `navigationRepaint`), a `navigator` object, `IntersectionObserver` (or + disable prefetch), `sessionStorage` (or disable `emitRouteChunkError`). + +Under **`createMemoryHistory` per page** (the MPA model this integration +uses), most of §§1–3 disappears. The host then only needs: `document` +defined; a `history` object with a readable `state`; a `location` +reflecting the page's logical URL (read once for `initialURL`); a +`location.href` setter / `location.replace()` wired to the native +page-open primitive; `URL`; and numeric `scrollX/scrollY`. No +popstate/event machinery — hardware back is a native pop. diff --git a/docs/en/guide/web-limitations.md b/docs/en/guide/web-limitations.md new file mode 100644 index 00000000..ee1338ae --- /dev/null +++ b/docs/en/guide/web-limitations.md @@ -0,0 +1,62 @@ +# Web Limitations + +The web platform provides a convenient development and preview environment, but has differences from the native Android/iOS platforms. This page documents known limitations. + +## Rendering + +- **DOM-based rendering**: `@lynx-js/web-core` renders Lynx components as custom HTML elements in the DOM, rather than using a native view hierarchy. CSS behavior follows browser standards, which may differ from Lynx's native layout engine in edge cases. +- **Performance**: For complex UIs with many elements or animations, native rendering will generally be faster. The web platform is best suited for development previews, not performance-critical production use. + +## Native Bridge + +- **`NativeModules` is unavailable**: On web, the Lynx `NativeModules` global does not exist. All method calls must go through the [web handler registry](/guide/web-method-implementations). +- **Unregistered methods**: Methods without a web handler return error code `-3` (module not registered). Check which methods have web handlers in the [built-in methods table](/guide/web-method-implementations#built-in-web-methods). + +## Storage + +- **Not encrypted**: `localStorage` is not encrypted, unlike native secure storage options. Do not store sensitive data (tokens, credentials) via `storage.*` methods on web in production. +- **Size limit**: `localStorage` has a ~5 MB per-origin limit in most browsers. Native storage does not have this restriction. +- **Synchronous**: `localStorage` operations are synchronous and block the main thread. For large datasets, this can cause jank. + +## Media + +- **Camera access**: `media.chooseMedia` with a camera source requires HTTPS. On `http://localhost` during development, camera access may be allowed by the browser, but in production, HTTPS is required. +- **File downloads**: `media.downloadFile` uses `fetch()` and is subject to CORS restrictions. The target URL must include appropriate CORS headers, or the download will fail. +- **File type filtering**: Browser `` accept filters are hints — users can override them and select any file type. + +## Network + +- **CORS**: All `fetch()` calls from the browser are subject to Cross-Origin Resource Sharing (CORS) policies. APIs that work from native (which has no CORS) may fail from web without server-side CORS headers. +- **Cookies**: Cookie handling differs between browser and native HTTP clients. Session management may behave differently on web. + +## Device APIs + +The following native capabilities are not available on web unless the browser provides equivalent APIs with user permission: + +- Geolocation (available via browser Geolocation API, requires HTTPS) +- Push notifications (available via browser Push API, requires service worker) +- Biometric authentication (not available) +- App-level deep linking (not applicable in browser context) +- Background processing (limited to service workers) + +## Feature Detection + +Write platform-aware code using error handling: + +```ts +import { callAsync } from 'sparkling-method'; + +try { + const result = await callAsync('nativeOnly.feature', params); + // Use native result +} catch (e) { + if (e.code === -2 || e.code === -3) { + // Not available on this platform + // Show alternative UI or skip the feature + } +} +``` + +Error codes: +- `-2`: `NativeModules` not available (running on web without a handler) +- `-3`: Module not registered (no web handler for this method) diff --git a/docs/en/guide/web-method-implementations.md b/docs/en/guide/web-method-implementations.md new file mode 100644 index 00000000..2ce7aaa5 --- /dev/null +++ b/docs/en/guide/web-method-implementations.md @@ -0,0 +1,147 @@ +# Web Method Implementations + +Sparkling method SDKs (navigation, storage, media) communicate with the native layer via `NativeModules` on Android/iOS. On web, `NativeModules` is unavailable — instead, a **web handler registry** dispatches method calls to browser-native implementations. + +## How It Works + +``` +App code → LynxPipe.call('router.open', params) + ↓ +sparkling-method detects web environment + ↓ +Web handler registry lookup by method name + ↓ +Browser-native implementation (History API, localStorage, etc.) +``` + +When `LynxPipe.call()` runs on the web: + +1. It checks if `NativeModules` is available — on web, it isn't. +2. Before returning an error, it looks up the method name in the **web handler registry**. +3. If a handler is registered, it's invoked with the same params and callback. +4. If no handler is found, it returns error code `-3` (module not registered). + +## Built-in Web Methods + +### Navigation (`sparkling-navigation`) + +| Method | Web Implementation | Notes | +|--------|-------------------|-------| +| `router.open` | Parses the `hybrid://` scheme, extracts the bundle name, uses the History API and fires a `CustomEvent` to swap the `` URL | Full scheme parameter support | +| `router.close` | `window.history.back()` | | + +### Storage (`sparkling-storage`) + +| Method | Web Implementation | Notes | +|--------|-------------------|-------| +| `storage.getItem` | `localStorage.getItem()` | 5 MB per-origin limit | +| `storage.setItem` | `localStorage.setItem()` | | +| `storage.removeItem` | `localStorage.removeItem()` | | + +### Media (`sparkling-media`) + +| Method | Web Implementation | Notes | +|--------|-------------------|-------| +| `media.chooseMedia` | `` with accept filters | Camera source requires HTTPS | +| `media.downloadFile` | `fetch()` + `URL.createObjectURL()` + download link | Subject to CORS restrictions | + +## Creating a Web Handler for a Custom Method + +If you've created a custom method SDK (see [Create a Custom Method](/guide/get-started/create-custom-method)), you can add web support by following these steps: + +### 1. Create the web handler + +Create `src/web/index.ts` in your method package: + +```ts +import { registerWebMethod } from 'sparkling-method'; + +registerWebMethod('myModule.myAction', (params, callback) => { + // Implement using browser APIs + const result = { /* ... */ }; + callback({ code: 0, data: result }); +}); + +registerWebMethod('myModule.anotherAction', (params, callback) => { + // ... + callback({ code: 0, data: {} }); +}); +``` + +### 2. Add subpath export + +In your method package's `package.json`, add a `./web` export: + +```json +{ + "exports": { + ".": "./dist/index.js", + "./web": "./dist/web/index.js" + } +} +``` + +### 3. Update module.config.json + +Add `"web"` to the `platforms` array: + +```json +{ + "name": "my-method", + "platforms": ["android", "ios", "web"], + "web": { + "entryPoint": "./src/web/index.ts", + "subpath": "./web" + } +} +``` + +### 4. Run autolink + +```bash +pnpm autolink +``` + +The CLI discovers the `web` platform entry and generates the appropriate imports so your web handler is loaded automatically. + +## Testing Web Handlers + +Web handlers are plain functions — you can test them in isolation: + +```ts +import { getWebMethodHandler } from 'sparkling-method'; + +// After importing your web handler module +import 'my-method/web'; + +test('myModule.myAction returns expected data', (done) => { + const handler = getWebMethodHandler('myModule.myAction'); + handler({ input: 'test' }, (response) => { + expect(response.code).toBe(0); + expect(response.data).toEqual({ /* expected */ }); + done(); + }); +}); +``` + +## Error Handling + +If a method is called on web without a registered handler, `LynxPipe` returns: + +```json +{ "code": -3, "message": "Module not registered" } +``` + +You can use this to implement graceful degradation: + +```ts +import { callAsync } from 'sparkling-method'; + +try { + const result = await callAsync('myModule.doThing', params); +} catch (e) { + if (e.code === -3) { + // Method not available on this platform — show fallback UI + } +} +``` diff --git a/docs/en/guide/web-platform.md b/docs/en/guide/web-platform.md new file mode 100644 index 00000000..e9e04fd8 --- /dev/null +++ b/docs/en/guide/web-platform.md @@ -0,0 +1,140 @@ +# Web Platform Guide + +Sparkling supports rendering Lynx bundles in the browser via the [Lynx web platform](https://lynxjs.org). This lets you preview and test your app in a browser without a native simulator — useful for rapid iteration, CI testing, and potentially web deployment. + +## Architecture + +``` +App TSX → rspeedy (multi-env) → *.lynx.bundle → Android/iOS native LynxView + → *.web.bundle → in browser +``` + +The same source code produces two outputs: +- **`*.lynx.bundle`** — consumed by native LynxView on Android/iOS +- **`*.web.bundle`** — consumed by `` custom element in the browser + +## New Projects + +When you run `npm create sparkling-app@latest`, the scaffolder asks: + +``` +? Enable web platform support? (preview in browser) (Y/n) +``` + +Answering **Yes** (the default) sets up the project with: +- `environments` config in `app.config.ts` for dual-output builds +- `dev:web`, `build:web`, and `run:web` scripts +- `sparkling-web-shell` devDependency + +You can also pass `--web` or `--no-web` as CLI flags: +```bash +npm create sparkling-app@latest my-app -- --web # enable web +npm create sparkling-app@latest my-app -- --no-web # disable web +``` + +## Existing Projects + +To add web support to an existing Sparkling project: + +### 1. Update `app.config.ts` + +Add the `environments` block and move `assetPrefix` into the `lynx` environment: + +```ts +const lynxConfig = defineConfig({ + source: { + entry: { + main: './src/pages/main/index.tsx', + }, + }, + output: { + filename: { + bundle: '[name].lynx.bundle', + }, + }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: { + output: { + assetPrefix: 'asset:///', + }, + }, + }, + plugins: [/* ... */], +}) +``` + +### 2. Add dependencies + +```bash +pnpm add -D sparkling-web-shell @lynx-js/web-core @lynx-js/web-elements +``` + +### 3. Add scripts to `package.json` + +```json +{ + "scripts": { + "dev:web": "sparkling-app-cli dev --platform web", + "build:web": "sparkling-app-cli build --platform web", + "run:web": "sparkling-app-cli run:web" + } +} +``` + +### 4. Run autolink + +```bash +pnpm autolink +``` + +This generates web method imports so that method SDKs (navigation, storage, etc.) work in the browser. + +## Development Workflow + +### Start the web dev server + +```bash +pnpm run:web +``` + +This builds your web bundles and starts the web shell server at `http://localhost:3000`. + +### Navigate between pages + +For multi-page apps, use the `?page=` query parameter: + +- `http://localhost:3000` — loads `main.web.bundle` (default) +- `http://localhost:3000?page=second` — loads `second.web.bundle` + +When using `sparkling-navigation`'s `router.open()` in your app code, page transitions are handled automatically via the History API. + +### Hot Module Replacement + +The rspeedy dev server provides HMR for web bundles. Changes to your source files are reflected in the browser without a full reload. + +### Debugging + +Use your browser's built-in DevTools (Chrome DevTools, Firefox DevTools, etc.) to inspect the DOM, debug JavaScript, profile performance, and monitor network requests. + +## Build for Web + +To produce web bundles without starting a dev server: + +```bash +pnpm build:web +``` + +Output goes to `dist/web/`. These bundles can be served by any static file server. + +## Next Steps + +- [Web Method Implementations](/guide/web-method-implementations) — how method SDKs work on web +- [Web Limitations](/guide/web-limitations) — what native features aren't available on web diff --git a/package.json b/package.json index 0828e1e9..ed2278a8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "author": "TikTok Cross Platform Team", "license": "Apache-2.0", "devDependencies": { + "playwright": "^1.58.2", "typescript": "^5.8.3" }, "engines": { diff --git a/packages/create-sparkling-app/src/create-app.ts b/packages/create-sparkling-app/src/create-app.ts index 44589595..903b76c7 100644 --- a/packages/create-sparkling-app/src/create-app.ts +++ b/packages/create-sparkling-app/src/create-app.ts @@ -47,6 +47,7 @@ import { askNamespace, askProjectName, askTemplate, + askWebPlatform, confirmInitGit, confirmInstall, confirmRemoveExistingDir, @@ -75,6 +76,45 @@ function ensureExecutable(filePath: string): void { } } +function removeWebSupport(projectDir: string): void { + // Remove web-related scripts and dependencies from package.json + const packageJsonPath = path.join(projectDir, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as Record; + + const scripts = (pkg.scripts ?? {}) as Record; + delete scripts["dev:web"]; + delete scripts["build:web"]; + delete scripts["run:web"]; + + const devDeps = (pkg.devDependencies ?? {}) as Record; + delete devDeps["sparkling-web-shell"]; + delete devDeps["@lynx-js/web-core"]; + delete devDeps["@lynx-js/web-elements"]; + + fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); + } + + // Remove environments config from app.config.ts, restoring flat assetPrefix + const appConfigPath = path.join(projectDir, "app.config.ts"); + if (fs.existsSync(appConfigPath)) { + let content = fs.readFileSync(appConfigPath, "utf8"); + // Remove the environments block and restore assetPrefix at top level + content = content.replace( + /\s*environments:\s*\{[\s\S]*?\n\s*\},\n/, + "\n" + ); + // Add assetPrefix back to output if not present + if (!content.includes("assetPrefix")) { + content = content.replace( + /output:\s*\{/, + "output: {\n assetPrefix: 'asset:///',", + ); + } + fs.writeFileSync(appConfigPath, content); + } +} + function toPascalCase(input: string): string { const base = input.includes("/") ? (input.split("/").pop() ?? input) : input; return base @@ -191,6 +231,8 @@ export async function createSparklingApp( const additionalTools = await askAdditionalTools(flags); + const enableWeb = await askWebPlatform(flags); + const defaultNamespace = deriveDefaultNamespace(packageName); const packageNamespace = await askNamespace(defaultNamespace, flags); @@ -275,6 +317,9 @@ export async function createSparklingApp( }); applyPackageNamespace(config.targetDir, packageNamespace); ensureExecutable(path.join(config.targetDir, "android", "gradlew")); + if (!enableWeb) { + removeWebSupport(config.targetDir); + } }, }); @@ -329,7 +374,7 @@ export async function createSparklingApp( await initializeGitRepo(distFolder); } - showCompletionNotes(targetDir, packageManager, didInstall); + showCompletionNotes(targetDir, packageManager, didInstall, enableWeb); p.outro(ui.success('Happy hacking!')); } diff --git a/packages/create-sparkling-app/src/create-app/post-create.ts b/packages/create-sparkling-app/src/create-app/post-create.ts index 90a7be82..fa57e1f2 100644 --- a/packages/create-sparkling-app/src/create-app/post-create.ts +++ b/packages/create-sparkling-app/src/create-app/post-create.ts @@ -112,7 +112,7 @@ export function detectPackageManager(): string { } } -export function showCompletionNotes(targetDir: string, packageManager?: string, didInstall = false): void { +export function showCompletionNotes(targetDir: string, packageManager?: string, didInstall = false, enableWeb = true): void { const formatScriptCommand = (script: string) => { const pm = packageManager ?? 'npm'; return pm === 'npm' ? `${pm} run ${script}` : `${pm} ${script}`; @@ -128,11 +128,17 @@ export function showCompletionNotes(targetDir: string, packageManager?: string, nextSteps.push(formatScriptCommand('run:ios')); nextSteps.push(formatScriptCommand('run:android')); + if (enableWeb) { + nextSteps.push(formatScriptCommand('run:web')); + } const tips = [ 'iOS: ensure Xcode Command Line Tools are installed.', 'Android: ensure ANDROID_HOME and SDK platforms are set.', ]; + if (enableWeb) { + tips.push('Web: run `run:web` to preview your app in the browser.'); + } p.note( [...nextSteps, '', ...tips.map(t => ui.tip(t))].join('\n'), diff --git a/packages/create-sparkling-app/src/create-app/types.ts b/packages/create-sparkling-app/src/create-app/types.ts index 45dd98b3..a15eedef 100644 --- a/packages/create-sparkling-app/src/create-app/types.ts +++ b/packages/create-sparkling-app/src/create-app/types.ts @@ -18,6 +18,7 @@ export interface CreateAppFlags { namespace?: string; 'app-id'?: string; verbose?: boolean; + web?: boolean; } export interface CreateSparklingAppOptions { diff --git a/packages/create-sparkling-app/src/create-app/user-prompts.ts b/packages/create-sparkling-app/src/create-app/user-prompts.ts index 5b3cf31b..ff9464a7 100644 --- a/packages/create-sparkling-app/src/create-app/user-prompts.ts +++ b/packages/create-sparkling-app/src/create-app/user-prompts.ts @@ -92,6 +92,18 @@ export async function askAdditionalTools(flags: { yes?: boolean }): Promise { + if (flags.web !== undefined) return flags.web; + if (!flags.yes) { + const enableWeb = await p.confirm({ + message: 'Enable web platform support? (preview in browser)', + initialValue: true, + }); + return checkCancel(enableWeb); + } + return true; +} + export async function askNamespace(defaultNamespace: string, flags: { yes?: boolean; namespace?: string; ['app-id']?: string }): Promise { const provided = flags.namespace ?? flags['app-id']; if (provided) return provided; diff --git a/packages/create-sparkling-app/src/init.ts b/packages/create-sparkling-app/src/init.ts index 64151c4d..154c22d3 100644 --- a/packages/create-sparkling-app/src/init.ts +++ b/packages/create-sparkling-app/src/init.ts @@ -22,7 +22,9 @@ function parseFlags(argv: string[]): { name?: string; flags: CreateAppFlags } { .option('--no-git', 'Skip git initialization') .option('--namespace ', 'Android package / iOS bundle id') .option('--app-id ', 'Alias for namespace') - .option('-v, --verbose', 'Enable verbose logging'); + .option('-v, --verbose', 'Enable verbose logging') + .option('--web', 'Include web platform support (default: true)') + .option('--no-web', 'Exclude web platform support'); const parsed = program.parse(argv, { from: 'user' }); const opts = parsed.opts<{ @@ -36,6 +38,7 @@ function parseFlags(argv: string[]): { name?: string; flags: CreateAppFlags } { namespace?: string; appId?: string; verbose?: boolean; + web?: boolean; }>(); const [name] = parsed.args as string[]; @@ -51,6 +54,7 @@ function parseFlags(argv: string[]): { name?: string; flags: CreateAppFlags } { 'app-id': opts.appId, templateVersion: opts.templateVersion, verbose: opts.verbose, + web: opts.web, }; return { name, flags }; diff --git a/packages/methods/sparkling-media/module.config.json b/packages/methods/sparkling-media/module.config.json index b8854497..a0d5719e 100644 --- a/packages/methods/sparkling-media/module.config.json +++ b/packages/methods/sparkling-media/module.config.json @@ -1,10 +1,15 @@ { "name": "sparkling-media", + "platforms": ["android", "ios", "web"], "packageName": "com.tiktok.sparkling.method", "moduleName": "Media", "androidDsl": "kts", "projectName": "sparkling-media", "ios": { "podspecPath": "ios/Sparkling-Media.podspec" + }, + "web": { + "entryPoint": "index.ts", + "bundleName": "media.bundle" } } diff --git a/packages/methods/sparkling-media/package.json b/packages/methods/sparkling-media/package.json index c416f89b..4bf3b94f 100644 --- a/packages/methods/sparkling-media/package.json +++ b/packages/methods/sparkling-media/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-media/src/__tests__/web/web.test.ts b/packages/methods/sparkling-media/src/__tests__/web/web.test.ts new file mode 100644 index 00000000..2e376bdc --- /dev/null +++ b/packages/methods/sparkling-media/src/__tests__/web/web.test.ts @@ -0,0 +1,193 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// The built sparkling-method dist is ESM, which the CommonJS jest runtime +// cannot import. Mock the registry with an in-test map. +type Handler = (params: unknown, cb: (r: unknown) => void) => void; +const registry = new Map(); +jest.mock( + 'sparkling-method/web-registry', + () => ({ + registerWebMethod: (name: string, handler: Handler) => registry.set(name, handler), + getWebMethodHandler: (name: string) => registry.get(name), + }), + { virtual: true }, +); +jest.mock('sparkling-method', () => ({}), { virtual: true }); +const handler = (name: string): Handler => registry.get(name)!; + +import '../../web'; + +// --- fake DOM/network globals ------------------------------------------- + +interface FakeInput { + type: string; + accept: string; + multiple: boolean; + capture?: string; + files: Array<{ name: string; size: number; type: string }> | null; + onchange: (() => void) | null; + _cancel: (() => void) | null; + addEventListener(type: string, cb: () => void): void; + click(): void; +} + +let lastInput: FakeInput | null; +let clickBehavior: 'select' | 'cancel' | 'empty'; +let selectedFiles: Array<{ name: string; size: number; type: string }>; +const anchorClicks: Array<{ href: string; download: string }> = []; + +function makeInput(): FakeInput { + const input: FakeInput = { + type: '', + accept: '', + multiple: false, + files: null, + onchange: null, + _cancel: null, + addEventListener(type, cb) { + if (type === 'cancel') this._cancel = cb; + }, + click() { + if (clickBehavior === 'cancel') { + this._cancel?.(); + } else { + this.files = clickBehavior === 'select' ? selectedFiles : []; + this.onchange?.(); + } + }, + }; + lastInput = input; + return input; +} + +beforeAll(() => { + (globalThis as Record).document = { + createElement: (tag: string) => { + if (tag === 'input') return makeInput(); + // anchor for downloadFile + return { + href: '', + download: '', + click() { + anchorClicks.push({ href: (this as { href: string }).href, download: (this as { download: string }).download }); + }, + }; + }, + }; + (globalThis as Record).URL = Object.assign(URL, { + createObjectURL: () => 'blob:mock', + revokeObjectURL: () => undefined, + }); +}); + +beforeEach(() => { + lastInput = null; + clickBehavior = 'select'; + selectedFiles = [{ name: 'a.png', size: 10, type: 'image/png' }]; + anchorClicks.length = 0; +}); + +function call(name: string, data: unknown): Promise<{ code: number; msg: string; data?: unknown }> { + return new Promise((resolve) => { + handler(name)({ containerID: 'c', protocolVersion: '1.0.0', data }, (r) => resolve(r as { code: number; msg: string; data?: unknown })); + }); +} + +describe('media.chooseMedia (web)', () => { + it('builds the accept string from mediaTypes and returns file descriptors', async () => { + const result = await call('media.chooseMedia', { mediaTypes: ['image'], maxCount: 1 }); + expect(lastInput?.accept).toBe('image/*'); + expect(result.code).toBe(1); + expect(result.data).toEqual([ + { tempFilePath: 'blob:mock', size: 10, type: 'image/png', name: 'a.png' }, + ]); + }); + + it('accepts both image and video and honors multiple', async () => { + selectedFiles = [ + { name: 'a.png', size: 1, type: 'image/png' }, + { name: 'b.mp4', size: 2, type: 'video/mp4' }, + ]; + const result = await call('media.chooseMedia', { mediaTypes: ['image', 'video'], maxCount: 2 }); + expect(lastInput?.accept).toBe('image/*,video/*'); + expect(lastInput?.multiple).toBe(true); + expect((result.data as unknown[]).length).toBe(2); + }); + + it('sets capture for the camera source', async () => { + await call('media.chooseMedia', { sourceType: 'camera', cameraType: 'front' }); + expect(lastInput?.capture).toBe('user'); + }); + + it('reports cancellation', async () => { + clickBehavior = 'cancel'; + expect(await call('media.chooseMedia', {})).toEqual({ code: 0, msg: 'User cancelled' }); + }); + + it('reports no file selected', async () => { + clickBehavior = 'empty'; + expect(await call('media.chooseMedia', {})).toEqual({ code: 0, msg: 'No file selected' }); + }); +}); + +describe('media.downloadFile (web)', () => { + it('requires a url', async () => { + expect(await call('media.downloadFile', {})).toEqual({ code: 0, msg: 'url is required' }); + }); + + it('downloads via an anchor click on success', async () => { + (globalThis as Record).fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(), + }); + const result = await call('media.downloadFile', { url: 'https://x/y', extension: 'png' }); + expect(result.code).toBe(1); + expect(anchorClicks[0].download).toBe('download.png'); + }); + + it('reports an HTTP error', async () => { + (globalThis as Record).fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 }); + expect((await call('media.downloadFile', { url: 'https://x/y' })).code).toBe(0); + }); +}); + +describe('media.uploadFile / uploadImage (web)', () => { + it('requires a url', async () => { + expect(await call('media.uploadFile', {})).toEqual({ code: 0, msg: 'url is required' }); + expect(await call('media.uploadImage', {})).toEqual({ code: 0, msg: 'url is required' }); + }); + + it('POSTs form data and returns the JSON response', async () => { + (globalThis as Record).FormData = class { + append() {} + }; + const fetchMock = jest.fn() + .mockResolvedValueOnce({ blob: async () => new Blob() }) // fetch(filePath) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 7 }) }); // upload POST + (globalThis as Record).fetch = fetchMock; + + const result = await call('media.uploadFile', { url: 'https://up', filePath: 'blob:file', params: { album: 'x' } }); + expect(result).toEqual({ code: 1, msg: 'ok', data: { id: 7 } }); + expect((fetchMock.mock.calls[1][1] as { method: string }).method).toBe('POST'); + }); + + it('reports an HTTP error from the upload', async () => { + (globalThis as Record).FormData = class { + append() {} + }; + (globalThis as Record).fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, blob: async () => new Blob() }); + expect((await call('media.uploadFile', { url: 'https://up' })).code).toBe(0); + }); +}); + +describe('media.saveDataURL (web)', () => { + it('is explicitly unsupported', async () => { + expect(await call('media.saveDataURL', { dataURL: 'data:...' })).toEqual({ + code: 0, + msg: 'media.saveDataURL is not supported on web', + }); + }); +}); diff --git a/packages/methods/sparkling-media/src/web/index.ts b/packages/methods/sparkling-media/src/web/index.ts new file mode 100644 index 00000000..2c644ea0 --- /dev/null +++ b/packages/methods/sparkling-media/src/web/index.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2025 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; +import type { WebMethodHandler } from 'sparkling-method'; + +registerWebMethod('media.chooseMedia', (params, callback) => { + const data = params.data as Record | null; + const mediaTypes = data?.mediaTypes as string[] | undefined; + const sourceType = data?.sourceType as string | undefined; + const maxCount = (data?.maxCount as number) ?? 1; + + // Build accept string from mediaTypes + const acceptParts: string[] = []; + if (mediaTypes?.includes('image')) acceptParts.push('image/*'); + if (mediaTypes?.includes('video')) acceptParts.push('video/*'); + const accept = acceptParts.length ? acceptParts.join(',') : 'image/*,video/*'; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = maxCount > 1; + + // Camera source: use capture attribute (mobile browsers only) + if (sourceType === 'camera') { + input.capture = (data?.cameraType as string) === 'front' ? 'user' : 'environment'; + } + + input.onchange = () => { + const files = Array.from(input.files ?? []); + if (!files.length) { + callback({ code: 0, msg: 'No file selected' }); + return; + } + + const results = files.slice(0, maxCount).map(file => ({ + tempFilePath: URL.createObjectURL(file), + size: file.size, + type: file.type, + name: file.name, + })); + + callback({ code: 1, msg: 'ok', data: results }); + }; + + // Handle user cancelling the file picker + input.addEventListener('cancel', () => { + callback({ code: 0, msg: 'User cancelled' }); + }); + + input.click(); +}); + +registerWebMethod('media.downloadFile', (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const extension = data?.extension as string | undefined; + const headers = data?.header as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + fetch(url, { headers }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then(blob => { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = `download.${extension || 'bin'}`; + a.click(); + URL.revokeObjectURL(objectUrl); + callback({ code: 1, msg: 'ok', data: { tempFilePath: objectUrl } }); + }) + .catch(e => { + callback({ code: 0, msg: `Download failed: ${e}` }); + }); +}); + +// Shared implementation for uploadFile and uploadImage (same API shape) +const uploadFileImpl: WebMethodHandler = (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const filePath = data?.filePath as string | undefined; + const headers = data?.header as Record | undefined; + const extraParams = data?.params as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + // filePath on web is a blob URL from chooseMedia + const filePromise = filePath + ? fetch(filePath).then(r => r.blob()) + : Promise.resolve(new Blob()); + + filePromise + .then(blob => { + const formData = new FormData(); + formData.append('file', blob); + if (extraParams && typeof extraParams === 'object') { + for (const [k, v] of Object.entries(extraParams)) { + formData.append(k, String(v)); + } + } + return fetch(url, { method: 'POST', headers, body: formData }); + }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then(json => callback({ code: 1, msg: 'ok', data: json })) + .catch(e => callback({ code: 0, msg: `Upload failed: ${e}` })); +}; + +registerWebMethod('media.uploadFile', uploadFileImpl); +registerWebMethod('media.uploadImage', uploadFileImpl); + +registerWebMethod('media.saveDataURL', (_params, callback) => { + // No web API to save directly to device photo album + callback({ code: 0, msg: 'media.saveDataURL is not supported on web' }); +}); diff --git a/packages/methods/sparkling-navigation/module.config.json b/packages/methods/sparkling-navigation/module.config.json index 3e892e58..a63028ef 100644 --- a/packages/methods/sparkling-navigation/module.config.json +++ b/packages/methods/sparkling-navigation/module.config.json @@ -1,6 +1,6 @@ { "name": "sparkling-navigation", - "platforms": ["android", "ios"], + "platforms": ["android", "ios", "web"], "version": "1.0.0", "description": "Router methods for navigation and page management in Sparkling apps", "methods": { diff --git a/packages/methods/sparkling-navigation/package.json b/packages/methods/sparkling-navigation/package.json index 2903bb18..b437a8fa 100644 --- a/packages/methods/sparkling-navigation/package.json +++ b/packages/methods/sparkling-navigation/package.json @@ -9,6 +9,26 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + }, + "./shim-host": { + "types": "./dist/src/shim-host/index.d.ts", + "default": "./dist/src/shim-host/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"], + "shim-host": ["dist/src/shim-host/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-navigation/src/__tests__/shim-host/manifest.test.ts b/packages/methods/sparkling-navigation/src/__tests__/shim-host/manifest.test.ts new file mode 100644 index 00000000..ecaa80b0 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/shim-host/manifest.test.ts @@ -0,0 +1,116 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { + matchSparklingRoute, + routeBundle, + findRouteByBundle, +} from '../../shim-host/manifest'; +import type { SparklingRouteManifest } from '../../shim-host/manifest'; + +const manifest: SparklingRouteManifest = { + version: 1, + base: '/', + routes: [ + { name: 'index', path: '/', entry: 'index' }, + { name: 'about', path: '/about', entry: 'about' }, + { name: 'users-id', path: '/users/:id()', entry: 'users-id' }, + { name: 'users-id-posts', path: '/users/:id()/posts', entry: 'users-id-posts' }, + { name: 'blog-slug', path: '/blog/:slug(.*)*', entry: 'blog-slug' }, + { name: 'files', path: '/files/:path(.*)+', entry: 'files' }, + { name: 'optional', path: '/opt/:tag?', entry: 'optional' }, + ], +}; + +describe('matchSparklingRoute', () => { + it('matches the index route', () => { + expect(matchSparklingRoute(manifest, '/')?.route.name).toBe('index'); + }); + + it('matches a static route', () => { + expect(matchSparklingRoute(manifest, '/about')?.route.name).toBe('about'); + }); + + it('matches a dynamic param and extracts it', () => { + const m = matchSparklingRoute(manifest, '/users/42'); + expect(m?.route.name).toBe('users-id'); + expect(m?.params).toEqual({ id: '42' }); + }); + + it('prefers a static segment over a dynamic one (specificity)', () => { + const m = matchSparklingRoute(manifest, '/users/42/posts'); + expect(m?.route.name).toBe('users-id-posts'); + expect(m?.params).toEqual({ id: '42' }); + }); + + it('matches a repeatable splat (zero or more)', () => { + expect(matchSparklingRoute(manifest, '/blog')?.route.name).toBe('blog-slug'); + const deep = matchSparklingRoute(manifest, '/blog/2026/07/hello'); + expect(deep?.route.name).toBe('blog-slug'); + expect(deep?.params).toEqual({ slug: ['2026', '07', 'hello'] }); + }); + + it('matches a required splat (one or more) but not the empty case', () => { + expect(matchSparklingRoute(manifest, '/files')).toBeNull(); + const m = matchSparklingRoute(manifest, '/files/a/b'); + expect(m?.params).toEqual({ path: ['a', 'b'] }); + }); + + it('matches an optional trailing param present or absent', () => { + expect(matchSparklingRoute(manifest, '/opt')?.route.name).toBe('optional'); + const m = matchSparklingRoute(manifest, '/opt/news'); + expect(m?.params).toEqual({ tag: 'news' }); + }); + + it('returns null for an unknown path', () => { + expect(matchSparklingRoute(manifest, '/nope/here')).toBeNull(); + }); + + it('matches a mid-path catch-all whose regex body contains a slash', () => { + // Nuxt emits [...id]/suffix as `/:id([^/]*)*/suffix`. The slash inside + // the custom regex must not be treated as a segment boundary. + const mid: SparklingRouteManifest = { + version: 1, + base: '/', + routes: [ + { name: 'id-suffix', path: '/:id([^/]*)*/suffix', entry: 'a' }, + { name: 'id-all', path: '/:id(.*)*', entry: 'b' }, + ], + }; + const m = matchSparklingRoute(mid, '/x/y/suffix'); + expect(m?.route.name).toBe('id-suffix'); + expect(m?.params).toEqual({ id: ['x', 'y'] }); + // The plain catch-all still wins when there is no trailing /suffix. + expect(matchSparklingRoute(mid, '/x/y')?.route.name).toBe('id-all'); + }); + + it('respects a non-root base', () => { + const based: SparklingRouteManifest = { + version: 1, + base: '/app', + routes: [{ name: 'home', path: '/', entry: 'index' }, { name: 'x', path: '/x', entry: 'x' }], + }; + expect(matchSparklingRoute(based, '/app')?.route.name).toBe('home'); + expect(matchSparklingRoute(based, '/app/x')?.route.name).toBe('x'); + expect(matchSparklingRoute(based, '/x')).toBeNull(); + }); +}); + +describe('routeBundle / findRouteByBundle', () => { + it('defaults the bundle name to .lynx.bundle', () => { + expect(routeBundle({ path: '/', entry: 'index' })).toBe('index.lynx.bundle'); + }); + + it('honors an explicit bundle name', () => { + expect(routeBundle({ path: '/', entry: 'index', bundle: 'home.lynx.bundle' })).toBe('home.lynx.bundle'); + }); + + it('finds a route by bundle, entry, or bare name', () => { + expect(findRouteByBundle(manifest, 'about.lynx.bundle')?.name).toBe('about'); + expect(findRouteByBundle(manifest, 'about')?.name).toBe('about'); + expect(findRouteByBundle(manifest, '/users-id.lynx.bundle')?.name).toBe('users-id'); + expect(findRouteByBundle(manifest, 'missing')).toBeUndefined(); + }); +}); diff --git a/packages/methods/sparkling-navigation/src/__tests__/shim-host/shim-host.test.ts b/packages/methods/sparkling-navigation/src/__tests__/shim-host/shim-host.test.ts new file mode 100644 index 00000000..7b696e41 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/shim-host/shim-host.test.ts @@ -0,0 +1,194 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { createSparklingNavigationHost, deriveInitialUrl } from '../../shim-host'; +import type { SparklingRouteManifest } from '../../shim-host/manifest'; +import { createMockPipe, MockPipe } from '../test-utils'; + +// open/navigate/close all funnel through the default pipe import. +jest.mock('sparkling-method', () => ({ call: jest.fn() }), { virtual: true }); + +const manifest: SparklingRouteManifest = { + version: 1, + origin: 'https://sparkling.app', + base: '/', + routes: [ + { name: 'index', path: '/', entry: 'index' }, + { name: 'about', path: '/about', entry: 'about', container: { title: 'About', hide_nav_bar: 1 } }, + { name: 'users-id', path: '/users/:id()', entry: 'users-id' }, + ], +}; + +function lastCall(mockPipe: MockPipe) { + const calls = mockPipe.call.mock.calls; + return calls[calls.length - 1]; +} + +function schemeOf(mockPipe: MockPipe): string { + const [, params] = lastCall(mockPipe); + return (params as { scheme: string }).scheme; +} + +describe('createSparklingNavigationHost', () => { + let mockPipe: MockPipe; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockPipe = jest.requireMock('sparkling-method') as unknown as MockPipe; + // router.open/close success by default + mockPipe.call.mockImplementation((_method: string, _params: unknown, cb: (r: unknown) => void) => { + cb({ code: 1, msg: 'ok' }); + }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + delete (globalThis as Record).lynx; + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('derives initialUrl from the manifest base when no container info exists', () => { + const host = createSparklingNavigationHost({ manifest }); + expect(host.initialUrl).toBe('https://sparkling.app/'); + }); + + it('opens an in-app route via router.open with the mapped bundle and __path', () => { + const host = createSparklingNavigationHost({ manifest }); + host.open('https://sparkling.app/about'); + + const [method] = lastCall(mockPipe); + expect(method).toBe('router.open'); + const scheme = schemeOf(mockPipe); + expect(scheme).toContain('hybrid://lynxview_page'); + expect(scheme).toContain('bundle=about.lynx.bundle'); + // full in-app URL rides along so the target page seeds its own location + expect(scheme).toContain(`__path=${encodeURIComponent('/about')}`); + }); + + it('accepts a relative URL and resolves it against initialUrl', () => { + const host = createSparklingNavigationHost({ manifest }); + host.open('/users/42?tab=posts'); + const scheme = schemeOf(mockPipe); + expect(scheme).toContain('bundle=users-id.lynx.bundle'); + expect(scheme).toContain(`__path=${encodeURIComponent('/users/42?tab=posts')}`); + }); + + it('merges per-route container params into the scheme', () => { + const host = createSparklingNavigationHost({ manifest }); + host.open('/about'); + const scheme = schemeOf(mockPipe); + expect(scheme).toContain('title=About'); + expect(scheme).toContain('hide_nav_bar=1'); + }); + + it('passes replace intent through to router.open options', () => { + const host = createSparklingNavigationHost({ manifest }); + host.open('/about', { replace: true }); + const [, params] = lastCall(mockPipe); + expect((params as { replace?: boolean }).replace).toBe(true); + }); + + it('routes external URLs through the webview scheme', () => { + const host = createSparklingNavigationHost({ manifest }); + host.open('https://example.com/docs?x=1'); + const scheme = schemeOf(mockPipe); + expect(scheme).toContain('hybrid://webview'); + expect(scheme).toContain(encodeURIComponent('https://example.com/docs?x=1')); + }); + + it('honors a custom external scheme template', () => { + const host = createSparklingNavigationHost({ + manifest: { ...manifest, externalScheme: 'hybrid://browser?target={url}' }, + }); + host.open('https://example.com/'); + expect(schemeOf(mockPipe)).toContain('hybrid://browser?target='); + }); + + it('calls onUnresolved when external scheme is disabled', () => { + const onUnresolved = jest.fn(); + const host = createSparklingNavigationHost({ + manifest: { ...manifest, externalScheme: null }, + onUnresolved, + }); + host.open('https://example.com/'); + expect(onUnresolved).toHaveBeenCalledWith('https://example.com/'); + expect(mockPipe.call).not.toHaveBeenCalled(); + }); + + it('calls onUnresolved for an in-app URL that matches no route', () => { + const onUnresolved = jest.fn(); + const host = createSparklingNavigationHost({ manifest, onUnresolved }); + host.open('/does/not/exist'); + expect(onUnresolved).toHaveBeenCalledWith('https://sparkling.app/does/not/exist'); + expect(mockPipe.call).not.toHaveBeenCalled(); + }); + + it('close() calls router.close', () => { + const host = createSparklingNavigationHost({ manifest }); + host.close(); + expect(lastCall(mockPipe)[0]).toBe('router.close'); + }); + + it('reload() re-opens the initial URL with replace', () => { + const host = createSparklingNavigationHost({ manifest, initialUrl: '/about' }); + host.reload(); + const [, params] = lastCall(mockPipe); + expect((params as { replace?: boolean }).replace).toBe(true); + expect((params as { scheme: string }).scheme).toContain('bundle=about.lynx.bundle'); + }); + + describe('isSameDocument', () => { + it('defaults to hash-only (every cross-path assign is a native open)', () => { + const host = createSparklingNavigationHost({ manifest }); + expect(host.isSameDocument(new URL('https://sparkling.app/users/1'), new URL('https://sparkling.app/users/1#a'))).toBe(true); + expect(host.isSameDocument(new URL('https://sparkling.app/users/1'), new URL('https://sparkling.app/users/2'))).toBe(false); + }); + + it('with sameBundleIsSameDocument, same-bundle paths share a document', () => { + const host = createSparklingNavigationHost({ manifest, sameBundleIsSameDocument: true }); + // both /users/1 and /users/2 map to users-id.lynx.bundle + expect(host.isSameDocument(new URL('https://sparkling.app/users/1'), new URL('https://sparkling.app/users/2'))).toBe(true); + // different bundles remain distinct documents + expect(host.isSameDocument(new URL('https://sparkling.app/about'), new URL('https://sparkling.app/users/2'))).toBe(false); + }); + + it('treats cross-origin as different documents', () => { + const host = createSparklingNavigationHost({ manifest }); + expect(host.isSameDocument(new URL('https://sparkling.app/'), new URL('https://example.com/'))).toBe(false); + }); + }); +}); + +describe('deriveInitialUrl', () => { + afterEach(() => { + delete (globalThis as Record).lynx; + }); + + it('prefers the explicit __path query item', () => { + (globalThis as Record).lynx = { + __globalProps: { queryItems: { __path: '/users/7?tab=likes' } }, + }; + expect(deriveInitialUrl(manifest)).toBe('https://sparkling.app/users/7?tab=likes'); + }); + + it('falls back to the route matching the loaded bundle', () => { + (globalThis as Record).lynx = { + __globalProps: { queryItems: { bundle: 'about.lynx.bundle' } }, + }; + expect(deriveInitialUrl(manifest)).toBe('https://sparkling.app/about'); + }); + + it('derives the bundle name from a dev-server url= item', () => { + (globalThis as Record).lynx = { + __globalProps: { queryItems: { url: 'http://127.0.0.1:5969/about.lynx.bundle' } }, + }; + expect(deriveInitialUrl(manifest)).toBe('https://sparkling.app/about'); + }); + + it('falls back to the base path when nothing matches', () => { + expect(deriveInitialUrl(manifest)).toBe('https://sparkling.app/'); + }); +}); diff --git a/packages/methods/sparkling-navigation/src/__tests__/web/web.test.ts b/packages/methods/sparkling-navigation/src/__tests__/web/web.test.ts new file mode 100644 index 00000000..44061072 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/web/web.test.ts @@ -0,0 +1,126 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Unit tests for the browser (`/web`) router handlers. These run in the + * node jest environment with a minimal hand-rolled `window`/`CustomEvent` + * so the browser-only code path is exercised without jsdom. + */ + +// The built sparkling-method dist is ESM, which the CommonJS jest runtime +// cannot import. Mock the registry with an in-test map (mirrors the real +// register/get contract) so the handler module can self-register here. +type Handler = (params: unknown, cb: (r: unknown) => void) => void; +const registry = new Map(); +jest.mock( + 'sparkling-method/web-registry', + () => ({ + registerWebMethod: (name: string, handler: Handler) => registry.set(name, handler), + getWebMethodHandler: (name: string) => registry.get(name), + }), + { virtual: true }, +); +const getWebMethodHandler = (name: string): Handler | undefined => registry.get(name); + +// Side-effect import self-registers the handlers. Registration does not +// touch `window`; the browser globals below are only needed when a handler +// is invoked, which always happens after beforeAll. +import '../../web'; + +interface CapturedEvent { + type: string; + detail?: unknown; +} + +const pushState = jest.fn(); +const back = jest.fn(); +const dispatched: CapturedEvent[] = []; + +beforeAll(() => { + class FakeCustomEvent { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + } + (globalThis as Record).CustomEvent = FakeCustomEvent; + (globalThis as Record).window = { + history: { pushState, back }, + dispatchEvent: (event: CapturedEvent) => { + dispatched.push(event); + return true; + }, + }; +}); + +beforeEach(() => { + pushState.mockClear(); + back.mockClear(); + dispatched.length = 0; +}); + +describe('router.open (web)', () => { + const open = () => getWebMethodHandler('router.open')!; + + it('is registered', () => { + expect(typeof open()).toBe('function'); + }); + + it('fails when scheme is missing', () => { + const cb = jest.fn(); + open()({ containerID: 'c', protocolVersion: '1.0.0', data: {} }, cb); + expect(cb).toHaveBeenCalledWith({ code: 0, msg: 'scheme is required' }); + expect(pushState).not.toHaveBeenCalled(); + }); + + it('pushes history and dispatches sparkling:navigate for a bundle scheme', () => { + const cb = jest.fn(); + open()( + { containerID: 'c', protocolVersion: '1.0.0', data: { scheme: 'hybrid://lynxview_page?bundle=about.lynx.bundle' } }, + cb, + ); + expect(pushState).toHaveBeenCalledWith( + { page: 'about', scheme: 'hybrid://lynxview_page?bundle=about.lynx.bundle' }, + '', + '?page=about', + ); + expect(dispatched[0]).toMatchObject({ type: 'sparkling:navigate', detail: { page: 'about' } }); + expect(cb).toHaveBeenCalledWith({ code: 1, msg: 'ok' }); + }); + + it('extracts the page name from a dev-server url= param', () => { + const cb = jest.fn(); + open()( + { containerID: 'c', protocolVersion: '1.0.0', data: { scheme: 'hybrid://lynxview_page?url=' + encodeURIComponent('http://127.0.0.1:5969/second.lynx.bundle') } }, + cb, + ); + expect(pushState).toHaveBeenCalledWith(expect.objectContaining({ page: 'second' }), '', '?page=second'); + expect(cb).toHaveBeenCalledWith({ code: 1, msg: 'ok' }); + }); + + it('fails when neither bundle nor url is present', () => { + const cb = jest.fn(); + open()({ containerID: 'c', protocolVersion: '1.0.0', data: { scheme: 'hybrid://lynxview_page?foo=bar' } }, cb); + expect(cb).toHaveBeenCalledWith({ code: 0, msg: 'No bundle or url param in scheme' }); + }); + + it('fails gracefully on an unparseable scheme', () => { + const cb = jest.fn(); + open()({ containerID: 'c', protocolVersion: '1.0.0', data: { scheme: 'not a url' } }, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ code: 0 })); + expect((cb.mock.calls[0][0] as { msg: string }).msg).toContain('Failed to parse scheme'); + }); +}); + +describe('router.close (web)', () => { + it('calls history.back and reports ok', () => { + const cb = jest.fn(); + getWebMethodHandler('router.close')!({ containerID: 'c', protocolVersion: '1.0.0', data: null }, cb); + expect(back).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ code: 1, msg: 'ok' }); + }); +}); diff --git a/packages/methods/sparkling-navigation/src/shim-host/index.ts b/packages/methods/sparkling-navigation/src/shim-host/index.ts new file mode 100644 index 00000000..22436f99 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/shim-host/index.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Sparkling implementation of the `NavigationHost` contract from + * `web-navigation-shim`. + * + * Layering (bottom-up): + * sparkling-navigation methods (router.open/close, scheme URLs) + * └─ this adapter: app URL ⇄ scheme URL via the route manifest + * └─ web-navigation-shim: history/location/popstate surface + * └─ any router framework (vue-router / Nuxt / …) + * + * The adapter is intentionally the ONLY Sparkling-aware layer: the shim + * above knows nothing about schemes, and the methods below know nothing + * about web navigation semantics. + */ + +import { navigate } from '../navigate/navigate'; +import { close } from '../close/close'; +import { open } from '../open/open'; +import { + DEFAULT_BASE_SCHEME, + DEFAULT_EXTERNAL_SCHEME, + DEFAULT_MANIFEST_ORIGIN, + PATH_QUERY_ITEM, + findRouteByBundle, + matchSparklingRoute, + routeBundle, +} from './manifest'; +import type { SparklingRouteManifest, SparklingManifestRoute } from './manifest'; + +export * from './manifest'; + +declare const lynx: { + __globalProps?: { queryItems?: Record }; +} | undefined; + +/** + * Structural copy of web-navigation-shim's contract so this package has no + * runtime (or even type-time) dependency on it. Kept in sync by the + * `shim-host` integration test in web-navigation-shim's consumers. + */ +interface HostOpenOptions { + replace?: boolean; + newWindow?: boolean; +} + +export interface SparklingNavigationHost { + readonly initialUrl: string; + readonly manifest: SparklingRouteManifest; + open(url: string, options?: HostOpenOptions): void; + close(): void; + reload(): void; + isSameDocument(from: URL, to: URL): boolean; +} + +export interface CreateSparklingHostOptions { + manifest: SparklingRouteManifest; + /** + * Override the initial app URL. Defaults to deriving it from + * `lynx.__globalProps.queryItems` (the `__path` item placed there by + * this adapter when opening the page, falling back to the route whose + * bundle the container loaded). + */ + initialUrl?: string; + /** + * Called when a navigation matches no manifest route and no external + * scheme applies. Default: `console.error`. + */ + onUnresolved?(url: string): void; + /** + * Widen the same-document policy: URLs served by the same Lynx bundle + * share a document (entry + popstate instead of a native open). Enable + * ONLY when bundles embed an in-page router that listens to popstate + * and serves several manifest paths (SPA-inside-MPA hybrid). With the + * default (false), the web's hash-only policy applies and every + * cross-path `location.assign` opens a native page — the right + * behavior for one-route-per-context setups like the Nuxt MPA glue. + */ + sameBundleIsSameDocument?: boolean; +} + +function manifestOrigin(manifest: SparklingRouteManifest): string { + return (manifest.origin ?? DEFAULT_MANIFEST_ORIGIN).replace(/\/+$/, ''); +} + +function appUrl(manifest: SparklingRouteManifest, pathWithQuery: string): string { + const origin = manifestOrigin(manifest); + return new URL(pathWithQuery, `${origin}/`).href; +} + +/** + * Derive this page's initial app URL from container query items. + * Precedence: explicit `__path` item → route table lookup by loaded + * bundle → app base path. + */ +export function deriveInitialUrl(manifest: SparklingRouteManifest): string { + const queryItems = (typeof lynx !== 'undefined' && lynx?.__globalProps?.queryItems) || {}; + + const explicit = queryItems[PATH_QUERY_ITEM]; + if (explicit) { + return appUrl(manifest, explicit); + } + + // The container knows which bundle it loaded (bundle= in prod, url= in dev). + const bundleParam = queryItems.bundle + ?? (queryItems.url ? queryItems.url.split('?')[0].split('/').pop() : undefined); + if (bundleParam) { + const route = findRouteByBundle(manifest, bundleParam); + if (route) { + // For a dynamic route deep-linked without __path the params are + // unknowable — land on the pattern path and let the app decide. + return appUrl(manifest, route.path); + } + } + + return appUrl(manifest, manifest.base ?? '/'); +} + +function containerParams(route: SparklingManifestRoute): Record { + const params: Record = {}; + const container = route.container ?? {}; + for (const key of Object.keys(container)) { + params[key] = String(container[key]); + } + return params; +} + +/** + * Create a Sparkling-backed NavigationHost. + * + * - In-app URLs are matched against the manifest and opened natively via + * `navigate()` (⇒ `router.open` with a `hybrid://…?bundle=…` scheme). + * The full app URL rides along in the `__path` query item so the target + * page can seed its own shim `location`. + * - Off-origin URLs (real web links) go through the manifest's + * `externalScheme` (default: the webview container). + * - `isSameDocument` widens the shim's default policy using the manifest: + * two URLs served by the same Lynx bundle share a document, so an + * in-page router (vue-router memory/web history) handles them locally. + */ +export function createSparklingNavigationHost( + options: CreateSparklingHostOptions, +): SparklingNavigationHost { + const { manifest } = options; + const origin = manifestOrigin(manifest); + const onUnresolved = options.onUnresolved + ?? ((url: string) => console.error(`[sparkling-navigation] no route or external scheme for ${url}`)); + + const initialUrl = options.initialUrl !== undefined + ? new URL(options.initialUrl, `${origin}/`).href + : deriveInitialUrl(manifest); + + function openExternal(url: string, opts: HostOpenOptions): void { + const template = manifest.externalScheme === undefined + ? DEFAULT_EXTERNAL_SCHEME + : manifest.externalScheme; + if (!template) { + onUnresolved(url); + return; + } + const scheme = template.replace('{url}', encodeURIComponent(url)); + open({ scheme, options: opts.replace ? { replace: true } : undefined }, () => {}); + } + + function openInApp(target: URL, opts: HostOpenOptions): void { + const match = matchSparklingRoute(manifest, target.pathname); + if (!match) { + onUnresolved(target.href); + return; + } + const pathWithQuery = target.pathname + target.search + target.hash; + navigate( + { + path: routeBundle(match.route), + baseScheme: manifest.baseScheme ?? DEFAULT_BASE_SCHEME, + options: { + ...(opts.replace ? { replace: true } : {}), + params: { + ...containerParams(match.route), + [PATH_QUERY_ITEM]: pathWithQuery, + }, + }, + }, + (result) => { + if (result.code !== 1) { + console.error(`[sparkling-navigation] open failed for ${target.href}: ${result.msg}`); + } + }, + ); + } + + const host: SparklingNavigationHost = { + initialUrl, + manifest, + open(url: string, opts: HostOpenOptions = {}) { + const target = new URL(url, initialUrl); + if (target.origin === origin) { + openInApp(target, opts); + } else { + openExternal(target.href, opts); + } + }, + close() { + close({}, () => {}); + }, + reload() { + host.open(host.initialUrl, { replace: true }); + }, + isSameDocument(from: URL, to: URL) { + if (from.origin !== to.origin) return false; + const hashOnly = from.pathname === to.pathname && from.search === to.search; + if (!options.sameBundleIsSameDocument || to.origin !== origin) { + return hashOnly; + } + const fromRoute = matchSparklingRoute(manifest, from.pathname)?.route; + const toRoute = matchSparklingRoute(manifest, to.pathname)?.route; + if (!fromRoute || !toRoute) return hashOnly; + // Same Lynx bundle ⇒ same JS heap ⇒ the in-page router owns it. + return routeBundle(fromRoute) === routeBundle(toRoute); + }, + }; + + return host; +} diff --git a/packages/methods/sparkling-navigation/src/shim-host/manifest.ts b/packages/methods/sparkling-navigation/src/shim-host/manifest.ts new file mode 100644 index 00000000..543b3b41 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/shim-host/manifest.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * The Sparkling route manifest: build-time metadata connecting app URLs + * (file-based routes) to Lynx bundles. + * + * Sparkling pages run in isolated JS heaps — no in-memory router can span + * them. This manifest is generated from the app's file-based routing + * convention (e.g. a Nuxt `pages/` directory) and embedded into every + * page bundle, so that any page can resolve "app URL → which bundle to + * open natively" without shared runtime state. + */ +export interface SparklingRouteManifest { + version: 1; + /** + * Logical origin for app URLs (never fetched; it only namespaces the + * app's URL space for WHATWG parsing). Default: `https://sparkling.app`. + */ + origin?: string; + /** Base path prefix of the app's URL space. Default `/`. */ + base?: string; + /** Base container scheme. Default `hybrid://lynxview_page`. */ + baseScheme?: string; + /** + * Scheme template for URLs outside the app's route table (external + * links). `{url}` is replaced with the encoded target URL. + * Default: `hybrid://webview?url={url}`. Set to `null` to disable. + */ + externalScheme?: string | null; + routes: SparklingManifestRoute[]; +} + +export interface SparklingManifestRoute { + /** Route name (unique), e.g. `users-id`. */ + name?: string; + /** + * vue-router style path pattern: static segments, `:param`, + * `:param(regexp)`, trailing `?` (optional) or `*`/`+` (repeatable). + * E.g. `/users/:id()`, `/blog/:slug(.*)*`. + */ + path: string; + /** Build entry name (rspeedy `source.entry` key). */ + entry: string; + /** Bundle file. Default: `${entry}.lynx.bundle`. */ + bundle?: string; + /** + * Extra container scheme params for this page (title, hide_nav_bar, …), + * merged into the scheme query when this route is opened. + */ + container?: Record; + /** Arbitrary route meta carried into the manifest for consumers. */ + meta?: Record; +} + +export interface SparklingRouteMatch { + route: SparklingManifestRoute; + params: Record; +} + +export const DEFAULT_MANIFEST_ORIGIN = 'https://sparkling.app'; +export const DEFAULT_BASE_SCHEME = 'hybrid://lynxview_page'; +export const DEFAULT_EXTERNAL_SCHEME = 'hybrid://webview?url={url}'; + +/** + * Query item used to carry the full in-app URL (path + search + hash) + * through the container scheme into the opened page, where it seeds the + * shim's `location`. + */ +export const PATH_QUERY_ITEM = '__path'; + +interface CompiledSegment { + type: 'static' | 'param'; + value: string; // static text or param name + pattern?: string; // custom regex for params + optional?: boolean; + repeatable?: boolean; +} + +interface CompiledRoute { + route: SparklingManifestRoute; + regex: RegExp; + keys: Array<{ name: string; repeatable: boolean }>; + score: number; +} + +const SEGMENT_RE = /^:([\w]+)(\(([^)]*)\))?([?+*]?)$/; + +/** + * Split a route path into segments on `/`, but only at parenthesis depth + * zero — a custom param regex body can itself contain a slash (Nuxt emits + * a mid-path catch-all as `:id([^slash]...)` followed by a literal + * segment), which a naive `split('/')` would mangle. + */ +function splitSegments(path: string): string[] { + const trimmed = path.replace(/^\/+|\/+$/g, ''); + const segments: string[] = []; + let depth = 0; + let current = ''; + for (const ch of trimmed) { + if (ch === '(') depth += 1; + else if (ch === ')') depth = Math.max(0, depth - 1); + if (ch === '/' && depth === 0) { + if (current) segments.push(current); + current = ''; + } else { + current += ch; + } + } + if (current) segments.push(current); + return segments; +} + +function compilePath(route: SparklingManifestRoute): CompiledRoute { + const segments = splitSegments(route.path); + const keys: CompiledRoute['keys'] = []; + let pattern = ''; + let score = 0; + + if (segments.length === 0) { + return { route, regex: /^\/?$/, keys, score: 100 }; + } + + for (const segment of segments) { + const match = SEGMENT_RE.exec(segment); + if (!match) { + // Static segment (may still contain :param embedded in text — keep + // it simple: treat fully-static segments only). + pattern += `/${segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`; + score += 100; + continue; + } + const [, name, , custom, modifier] = match; + const repeatable = modifier === '*' || modifier === '+'; + const optional = modifier === '?' || modifier === '*'; + const body = custom && custom.length > 0 ? custom : '[^/]+'; + keys.push({ name, repeatable }); + if (repeatable) { + // Match across segments; capture everything after the slash. + pattern += optional ? `(?:/(${body}(?:/${body})*))?` : `/(${body}(?:/${body})*)`; + score += 10; + } else if (optional) { + pattern += `(?:/(${body}))?`; + score += 30; + } else { + pattern += `/(${body})`; + score += 40; + } + } + + return { route, regex: new RegExp(`^${pattern}/?$`), keys, score }; +} + +let compiledCache: WeakMap | undefined; + +function compiled(manifest: SparklingRouteManifest): CompiledRoute[] { + compiledCache ??= new WeakMap(); + let list = compiledCache.get(manifest); + if (!list) { + list = manifest.routes.map(compilePath).sort((a, b) => b.score - a.score); + compiledCache.set(manifest, list); + } + return list; +} + +function stripBase(pathname: string, base: string | undefined): string | null { + const normalizedBase = (base ?? '/').replace(/\/+$/, ''); + if (!normalizedBase) return pathname; + if (pathname === normalizedBase) return '/'; + if (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length); + return null; +} + +/** + * Match an app pathname against the manifest. Returns the winning route + * (static segments outrank params) and extracted params. + */ +export function matchSparklingRoute( + manifest: SparklingRouteManifest, + pathname: string, +): SparklingRouteMatch | null { + const path = stripBase(pathname, manifest.base); + if (path === null) return null; + for (const entry of compiled(manifest)) { + const match = entry.regex.exec(path); + if (!match) continue; + const params: Record = {}; + entry.keys.forEach((key, i) => { + const raw = match[i + 1]; + if (raw === undefined) return; + params[key.name] = key.repeatable ? raw.split('/') : raw; + }); + return { route: entry.route, params }; + } + return null; +} + +/** Bundle file name for a manifest route. */ +export function routeBundle(route: SparklingManifestRoute): string { + return route.bundle ?? `${route.entry}.lynx.bundle`; +} + +/** Find a manifest route by its bundle file (or entry) name. */ +export function findRouteByBundle( + manifest: SparklingRouteManifest, + bundleOrEntry: string, +): SparklingManifestRoute | undefined { + const name = bundleOrEntry.replace(/^(?:\.\/|\/)+/, ''); + return manifest.routes.find( + (route) => routeBundle(route) === name + || route.entry === name + || `${route.entry}.lynx.bundle` === name, + ); +} diff --git a/packages/methods/sparkling-navigation/src/web/index.ts b/packages/methods/sparkling-navigation/src/web/index.ts new file mode 100644 index 00000000..7088e683 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/web/index.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; + +registerWebMethod('router.open', (params, callback) => { + const scheme = (params.data as Record)?.scheme as string | undefined; + + if (!scheme) { + callback({ code: 0, msg: 'scheme is required' }); + return; + } + + try { + const url = new URL(scheme); + const bundleParam = url.searchParams.get('bundle'); + const urlParam = url.searchParams.get('url'); + + // Extract page name (without extension). + // Web shell constructs the full URL as /${page}.lynx.bundle + let pageName: string; + if (urlParam) { + // Dev mode: url param is a full URL, extract the basename + const urlPath = new URL(urlParam).pathname; + pageName = urlPath.replace(/^\//, '').replace(/\.lynx\.bundle$/, ''); + } else if (bundleParam) { + pageName = bundleParam.replace(/\.lynx\.bundle$/, ''); + } else { + callback({ code: 0, msg: 'No bundle or url param in scheme' }); + return; + } + + // Push browser history state + const state = { page: pageName, scheme }; + window.history.pushState(state, '', `?page=${encodeURIComponent(pageName)}`); + + // Dispatch custom event for web shell to swap + window.dispatchEvent(new CustomEvent('sparkling:navigate', { + detail: { page: pageName, state }, + })); + + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `Failed to parse scheme: ${e}` }); + } +}); + +registerWebMethod('router.close', (_params, callback) => { + window.history.back(); + callback({ code: 1, msg: 'ok' }); +}); diff --git a/packages/methods/sparkling-storage/package.json b/packages/methods/sparkling-storage/package.json index 9764c410..4fb6c63f 100644 --- a/packages/methods/sparkling-storage/package.json +++ b/packages/methods/sparkling-storage/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-storage/src/__tests__/web/web.test.ts b/packages/methods/sparkling-storage/src/__tests__/web/web.test.ts new file mode 100644 index 00000000..c1ce5daf --- /dev/null +++ b/packages/methods/sparkling-storage/src/__tests__/web/web.test.ts @@ -0,0 +1,100 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// The built sparkling-method dist is ESM, which the CommonJS jest runtime +// cannot import. Mock the registry with an in-test map. +type Handler = (params: unknown, cb: (r: unknown) => void) => void; +const registry = new Map(); +jest.mock( + 'sparkling-method/web-registry', + () => ({ + registerWebMethod: (name: string, handler: Handler) => registry.set(name, handler), + getWebMethodHandler: (name: string) => registry.get(name), + }), + { virtual: true }, +); +const handler = (name: string): Handler => registry.get(name)!; + +import '../../web'; + +let store: Map; + +beforeAll(() => { + (globalThis as Record).localStorage = { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + store.delete(k); + }, + }; +}); + +beforeEach(() => { + store = new Map(); +}); + +function call(name: string, data: unknown): { code: number; msg: string; data?: unknown } { + let result: { code: number; msg: string; data?: unknown } = { code: -99, msg: '' }; + handler(name)({ containerID: 'c', protocolVersion: '1.0.0', data }, (r) => { + result = r as typeof result; + }); + return result; +} + +describe('storage.setItem / getItem / removeItem (web)', () => { + it('setItem then getItem round-trips a string under the namespaced key', () => { + expect(call('storage.setItem', { key: 'token', data: 'abc' })).toEqual({ code: 1, msg: 'ok' }); + expect(store.get('sparkling:token')).toBe('abc'); + expect(call('storage.getItem', { key: 'token' })).toEqual({ code: 1, msg: 'ok', data: 'abc' }); + }); + + it('namespaces by biz when provided', () => { + call('storage.setItem', { key: 'k', data: 'v', biz: 'profile' }); + expect(store.get('sparkling:profile:k')).toBe('v'); + expect(call('storage.getItem', { key: 'k', biz: 'profile' }).data).toBe('v'); + }); + + it('JSON-stringifies non-string values on setItem', () => { + call('storage.setItem', { key: 'obj', data: { a: 1 } }); + expect(store.get('sparkling:obj')).toBe('{"a":1}'); + }); + + it('getItem returns null for a missing key', () => { + expect(call('storage.getItem', { key: 'nope' })).toEqual({ code: 1, msg: 'ok', data: null }); + }); + + it('removeItem deletes the namespaced key', () => { + call('storage.setItem', { key: 'k', data: 'v' }); + expect(call('storage.removeItem', { key: 'k' })).toEqual({ code: 1, msg: 'ok' }); + expect(store.has('sparkling:k')).toBe(false); + }); + + it('each handler rejects a missing key', () => { + for (const name of ['storage.getItem', 'storage.setItem', 'storage.removeItem']) { + expect(call(name, {})).toEqual({ code: 0, msg: 'key is required' }); + } + }); + + it('reports localStorage errors as code 0', () => { + const original = (globalThis as Record).localStorage; + (globalThis as Record).localStorage = { + getItem: () => { + throw new Error('quota'); + }, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => { + throw new Error('quota'); + }, + }; + expect(call('storage.getItem', { key: 'k' }).code).toBe(0); + expect(call('storage.setItem', { key: 'k', data: 'v' }).code).toBe(0); + expect(call('storage.removeItem', { key: 'k' }).code).toBe(0); + (globalThis as Record).localStorage = original; + }); +}); diff --git a/packages/methods/sparkling-storage/src/web/index.ts b/packages/methods/sparkling-storage/src/web/index.ts new file mode 100644 index 00000000..e99eaaf5 --- /dev/null +++ b/packages/methods/sparkling-storage/src/web/index.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; + +function storageKey(key: string, biz?: string): string { + return biz ? `sparkling:${biz}:${key}` : `sparkling:${key}`; +} + +registerWebMethod('storage.getItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + const value = localStorage.getItem(storageKey(key, biz)); + callback({ code: 1, msg: 'ok', data: value }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); + +registerWebMethod('storage.setItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const value = data?.data; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + localStorage.setItem( + storageKey(key, biz), + typeof value === 'string' ? value : JSON.stringify(value), + ); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); + +registerWebMethod('storage.removeItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + localStorage.removeItem(storageKey(key, biz)); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); diff --git a/packages/nuxt-sparkling/README.md b/packages/nuxt-sparkling/README.md new file mode 100644 index 00000000..b3f1c8a1 --- /dev/null +++ b/packages/nuxt-sparkling/README.md @@ -0,0 +1,108 @@ +# nuxt-sparkling + +A Nuxt module that drives **Sparkling native navigation** from Nuxt's +file-based `pages/` — the multi-page (MPA) model, where each page is its +own Lynx bundle in its own JS heap, connected by the native navigation +stack rather than an in-memory router. + +## Why this is not VueLynx's memory router + +VueLynx already runs a Vue Router SPA inside a *single* LynxView using a +memory history. That keeps every route in one JS heap. + +This package targets the opposite model: **multiple** LynxViews, each a +separate native page with a separate JS heap, wired together by Sparkling +navigation. Because the heaps don't share memory, the router can't hold +the route table in RAM and navigate in place. Instead: + +1. At **build time**, the `pages/` tree is compiled into a **route + manifest** (`web-navigation-shim` → `sparkling-navigation/shim-host` + format) and embedded so any page can resolve *URL → which bundle to + open* without shared runtime state. +2. At **runtime**, each page installs `web-navigation-shim` over a + Sparkling `NavigationHost`. In-page history works normally; a + navigation to a *different* bundle hands off to `router.open` (native + push) with a sparkling scheme URL; back at the bottom of the in-page + stack becomes `router.close` (native pop). + +``` +Nuxt pages/ ──build──▶ Sparkling route manifest ──┐ + ▼ +vue-router (createWebHistory / memory) │ + │ router.push / / navigateTo │ + ▼ │ +web-navigation-shim (history/location/popstate) │ + │ location.assign(cross-bundle) │ + ▼ ▼ +sparkling-navigation/shim-host ──▶ router.open(hybrid://…?bundle=…&__path=…) + router.close +``` + +## Install + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['nuxt-sparkling/module'], + sparkling: { + origin: 'https://my.app', // logical URL space for app routes + mode: 'memory', // 'memory' (pure MPA) | 'web' (SPA-in-MPA) + strict: false, // fail build on blocking diagnostics + }, +}) +``` + +```ts +// app/router.options.ts +import { sparklingHistory, sparklingScrollBehavior } from 'nuxt-sparkling/runtime/history' +import manifest from '#sparkling/route-manifest' + +export default { + history: () => sparklingHistory(manifest, { mode: 'memory' }), + scrollBehavior: sparklingScrollBehavior, // returns false: native owns scroll +} +``` + +The module also turns off Nuxt subsystems that assume a browser document +and would throw/hang in a Lynx JS context: `emitRouteChunkError`, +`appManifest`, `navigationRepaint`, `restoreState` (see +`docs/en/guide/nuxt-web-api-dependencies.md`). + +## What the manifest generator does + +`pagesToSparklingManifest(pages, options)` transforms the `NuxtPage[]` +tree Nuxt hands to the `pages:extend` hook into a flat manifest. Because +each URL maps to exactly one bundle, nested `` outlets are +**flattened** to absolute paths (an index child wins its parent's URL), +and anything that can't cross a JS heap is reported as a **diagnostic** +(`degraded` = works with a caveat; blocking = can't work). See +`analyzePages()` for the per-feature support report the module prints at +build time. + +## Feature support (MPA) + +| Nuxt feature | Status | Notes | +|---|---|---| +| Static / index routes | ✅ supported | one bundle per route | +| Dynamic `[id]`, catch-all `[...slug]`, optional `[[id]]` | ✅ supported | whole-segment params match at runtime | +| Route groups `(group)` | ✅ supported | URL-transparent; `groups` carried in meta | +| `navigateTo` / `` / `router.push` across pages | ✅ supported | become native `router.open` | +| In-page history back/forward, hash | ✅ supported | within a bundle (`mode: 'web'`) | +| Nested layout via parent `` outlet | ⚠️ degraded | wrapper not kept mounted across child heaps; duplicate into children or use a native container | +| Mixed literal+param segment (`prefix-[id]`) | ⚠️ degraded | native open works; in-page param match best-effort | +| Function `redirect` in `definePageMeta` | ⚠️ degraded | runs after the target page boots | +| Named views (``) | ❌ unsupported | one outlet per page | + +Full traversal and rationale: `docs/en/guide/nuxt-sparkling-compat.md`. + +## Verification + +`__tests__/` (plain Node, no jsdom): + +- `nuxt-pages.spec.ts` — ports Nuxt's official `pages.test.ts` fixtures + (v4.4.8) and asserts the flattened manifest + diagnostics for every + routing pattern. +- `integration.spec.ts` — Nuxt page tree → manifest → Sparkling host → + shim → **real vue-router**, driving the actual `NativeModules.spkPipe` + bridge: deep-link boot, cross-bundle `router.open` handoff (scheme + + `__path`), native `router.close`, and webview handoff for external URLs. diff --git a/packages/nuxt-sparkling/__tests__/integration.spec.ts b/packages/nuxt-sparkling/__tests__/integration.spec.ts new file mode 100644 index 00000000..527578fc --- /dev/null +++ b/packages/nuxt-sparkling/__tests__/integration.spec.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Full-stack integration: Nuxt page tree → Sparkling manifest → Sparkling + * NavigationHost → web-navigation-shim → real vue-router. Proves the MPA + * wiring end to end: + * - booting a page at its deep-linked route, + * - a router.push to a different bundle handing off to the native host + * (router.open with the mapped scheme + __path), + * - back at the bottom of the stack becoming a native close. + * + * The native bridge is exercised for real: we install a `NativeModules` + * global with a `spkPipe` module (the exact seam sparkling-method's pipe + * routes through), so router.open/close flow through the real pipe. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { pagesToSparklingManifest } from '../src/manifest'; +import type { NuxtLikePage } from '../src/manifest'; + +interface PipeEnvelope { containerID: string; protocolVersion: string; data: Record } +const spkPipe = vi.fn( + (_method: string, _envelope: PipeEnvelope, cb?: (r: unknown) => void) => { + if (typeof cb === 'function') cb({ code: 1, msg: 'ok' }); + }, +); +(globalThis as Record).NativeModules = { spkPipe: { call: spkPipe } }; + +const pages: NuxtLikePage[] = [ + { name: 'index', path: '/', file: 'pages/index.vue', children: [] }, + { name: 'about', path: '/about', file: 'pages/about.vue', children: [] }, + { name: 'users-id', path: '/users/:id()', file: 'pages/users/[id].vue', children: [] }, +]; + +const { manifest } = pagesToSparklingManifest(pages, { origin: 'https://sparkling.app' }); + +let createSparklingNavigationHost: typeof import('sparkling-navigation/shim-host').createSparklingNavigationHost; +let createNavigationShim: typeof import('web-navigation-shim').createNavigationShim; +let vueRouter: typeof import('vue-router'); + +beforeAll(async () => { + ({ createSparklingNavigationHost } = await import('sparkling-navigation/shim-host')); + ({ createNavigationShim } = await import('web-navigation-shim')); + vueRouter = await import('vue-router'); +}); + +beforeEach(() => spkPipe.mockClear()); + +const Empty = { render: () => null }; + +function calls(method: string): PipeEnvelope[] { + return spkPipe.mock.calls.filter((c) => c[0] === method).map((c) => c[1] as PipeEnvelope); +} +function schemes(method = 'router.open'): string[] { + return calls(method).map((e) => e.data.scheme as string); +} + +describe('Nuxt MPA navigation over Sparkling', () => { + it('boots at the deep-linked route and hands cross-bundle navigation to router.open', async () => { + // Simulate a page opened at /users/42 (a fresh JS heap). + const host = createSparklingNavigationHost({ manifest, initialUrl: '/users/42' }); + const shim = createNavigationShim(host); + shim.install(globalThis as unknown as Record, { force: true }); + + const router = vueRouter.createRouter({ + history: vueRouter.createWebHistory(), + routes: [ + { path: '/', name: 'index', component: Empty }, + { path: '/about', name: 'about', component: Empty }, + { path: '/users/:id()', name: 'users-id', component: Empty }, + ], + }); + + // Boot at the shim's initial URL (what the Nuxt router plugin does). + await router.push(shim.location.pathname + shim.location.search); + await router.isReady(); + expect(router.currentRoute.value.name).toBe('users-id'); + expect(router.currentRoute.value.params).toEqual({ id: '42' }); + + // The Nuxt↔Sparkling glue expresses a cross-bundle navigation as: + // intercept and hand off via location.assign(target). + router.beforeEach((to) => { + shim.location.assign(to.fullPath); + return false; + }); + + await router.push('/about').catch(() => {}); + const opened = schemes(); + expect(opened.length).toBe(1); + expect(opened[0]).toContain('bundle=about.lynx.bundle'); + expect(opened[0]).toContain(`__path=${encodeURIComponent('/about')}`); + // Router stayed put (handoff aborted the in-context navigation). + expect(router.currentRoute.value.name).toBe('users-id'); + }); + + it('back at the bottom of the local stack becomes router.close (native pop)', () => { + const host = createSparklingNavigationHost({ manifest, initialUrl: '/about' }); + const shim = createNavigationShim(host); + shim.history.back(); + expect(calls('router.close').length).toBe(1); + }); + + it('external URLs route through the webview container scheme', () => { + const host = createSparklingNavigationHost({ manifest, initialUrl: '/' }); + const shim = createNavigationShim(host); + shim.window.open('https://example.com/help'); + expect(schemes()[0]).toContain('hybrid://webview'); + expect(schemes()[0]).toContain(encodeURIComponent('https://example.com/help')); + }); +}); diff --git a/packages/nuxt-sparkling/__tests__/nuxt-pages.spec.ts b/packages/nuxt-sparkling/__tests__/nuxt-pages.spec.ts new file mode 100644 index 00000000..50063288 --- /dev/null +++ b/packages/nuxt-sparkling/__tests__/nuxt-pages.spec.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Ports Nuxt's official `pages:generateRoutesFromFiles` fixtures + * (packages/nuxt/test/pages.test.ts @ v4.4.8). Each fixture's `output` is + * the exact `NuxtPage[]` Nuxt hands to the `pages:extend` hook. We feed + * those trees through `pagesToSparklingManifest` and assert the flattened + * MPA manifest, then confirm the manifest matches real URLs via the + * runtime matcher. This is the acceptance traversal: every Nuxt routing + * feature is exercised and classified supported / degraded / unsupported. + */ + +import { describe, expect, it } from 'vitest'; +import { pagesToSparklingManifest } from '../src/manifest'; +import type { NuxtLikePage } from '../src/manifest'; +import { matchSparklingRoute } from 'sparkling-navigation/shim-host'; + +const pagesDir = 'pages'; +const layerDir = 'layer/pages'; + +/** Build a manifest and index its routes by path for assertions. */ +function build(pages: NuxtLikePage[]) { + const { manifest, diagnostics } = pagesToSparklingManifest(pages, { origin: 'https://sparkling.app' }); + const byPath = new Map(manifest.routes.map((r) => [r.path, r])); + return { manifest, diagnostics, byPath }; +} + +describe('index pages', () => { + it('flattens nested index files to absolute paths', () => { + const { byPath } = build([ + { name: 'index', path: '/', file: `${pagesDir}/index.vue`, children: [] }, + { name: 'parent', path: '/parent', file: `${pagesDir}/parent/index.vue`, children: [] }, + { name: 'parent-child', path: '/parent/child', file: `${pagesDir}/parent/child/index.vue`, children: [] }, + ]); + expect([...byPath.keys()].sort()).toEqual(['/', '/parent', '/parent/child']); + expect(byPath.get('/parent/child')?.bundle).toBe('parent-child.lynx.bundle'); + }); +}); + +describe('parent/child nested routes (degraded: outlet cannot cross heaps)', () => { + it('flattens child to an absolute path and flags the wrapper', () => { + const { byPath, diagnostics } = build([ + { + name: 'parent', + path: '/parent', + file: `${pagesDir}/parent.vue`, + children: [ + { name: 'parent-child', path: 'child', file: `${pagesDir}/parent/child.vue`, children: [] }, + ], + }, + ]); + expect(byPath.has('/parent')).toBe(true); + expect(byPath.has('/parent/child')).toBe(true); + expect(byPath.get('/parent/child')?.bundle).toBe('parent-child.lynx.bundle'); + // The wrapper parent.vue is reported as a degraded nested outlet. + expect(diagnostics.find((d) => d.kind === 'nested-outlet' && d.path === '/parent')?.degraded).toBe(true); + }); +}); + +describe('catch-all routes', () => { + it('supports top-level and nested catch-all', () => { + const { byPath } = build([ + { name: 'index', path: '/', file: `${pagesDir}/index.vue`, children: [] }, + { + name: 'slug', + path: '/:slug(.*)*', + file: `${pagesDir}/[...slug].vue`, + children: [ + { name: 'slug-id', path: ':id()', file: `${pagesDir}/[...slug]/[id].vue`, children: [] }, + ], + }, + ]); + expect(byPath.has('/:slug(.*)*')).toBe(true); + expect(byPath.has('/:slug(.*)*/:id()')).toBe(true); + // Runtime matching of the catch-all: + const { manifest } = build([ + { name: 'slug', path: '/:slug(.*)*', file: `${pagesDir}/[...slug].vue`, children: [] }, + ]); + const m = matchSparklingRoute(manifest, '/a/b/c'); + expect(m?.route.name).toBe('slug'); + expect(m?.params).toEqual({ slug: ['a', 'b', 'c'] }); + }); + + it('supports a catch-all in the middle of a path (regex body contains a slash)', () => { + const { byPath, manifest } = build([ + { name: 'id', path: '/:id(.*)*', file: `${pagesDir}/[...id]/index.vue`, children: [] }, + { name: 'id-suffix', path: '/:id([^/]*)*/suffix', file: `${pagesDir}/[...id]/suffix.vue`, children: [] }, + ]); + expect(byPath.has('/:id(.*)*')).toBe(true); + expect(byPath.has('/:id([^/]*)*/suffix')).toBe(true); + // Runtime matching respects the trailing literal segment. + const m = matchSparklingRoute(manifest, '/a/b/suffix'); + expect(m?.route.name).toBe('id-suffix'); + expect(m?.params).toEqual({ id: ['a', 'b'] }); + }); +}); + +describe('dynamic and optional params', () => { + it('supports required, optional, prefixed and nested dynamic params', () => { + const { byPath, diagnostics, manifest } = build([ + { name: 'index', path: '/', file: `${pagesDir}/index.vue`, children: [] }, + { name: 'slug', path: '/:slug()', file: `${pagesDir}/[slug].vue`, children: [] }, + { + path: '/:foo?', + file: `${pagesDir}/[[foo]]`, + children: [{ name: 'foo', path: '', file: `${pagesDir}/[[foo]]/index.vue`, children: [] }], + }, + { name: 'opt-slug', path: '/opt/:slug?', file: `${pagesDir}/opt/[[slug]].vue`, children: [] }, + { name: 'nonopt-slug', path: '/nonopt/:slug()', file: `${pagesDir}/nonopt/[slug].vue`, children: [] }, + { name: 'optional-opt', path: '/optional/:opt?', file: `${pagesDir}/optional/[[opt]].vue`, children: [] }, + { name: 'sub-route-slug', path: '/:sub?/route-:slug()', file: `${pagesDir}/[[sub]]/route-[slug].vue`, children: [] }, + { name: 'optional-prefix-opt', path: '/optional/prefix-:opt?', file: `${pagesDir}/optional/prefix-[[opt]].vue`, children: [] }, + { name: 'optional-opt-postfix', path: '/optional/:opt?-postfix', file: `${pagesDir}/optional/[[opt]]-postfix.vue`, children: [] }, + ]); + expect(byPath.has('/:slug()')).toBe(true); + expect(byPath.has('/opt/:slug?')).toBe(true); + // The [[foo]] index child collapses onto its parent's '/:foo?' URL. + expect(byPath.get('/:foo?')?.bundle).toBe('foo.lynx.bundle'); + + // Whole-segment params match at runtime: + expect(matchSparklingRoute(manifest, '/hello')?.route.name).toBe('slug'); + // /opt matches /opt/:slug? with the optional slug absent. + expect(matchSparklingRoute(manifest, '/opt')?.route.name).toBe('opt-slug'); + expect(matchSparklingRoute(manifest, '/opt/x')?.params).toEqual({ slug: 'x' }); + expect(matchSparklingRoute(manifest, '/optional')?.route.name).toBe('optional-opt'); + + // Mixed literal+param segments (prefix-:opt, route-:slug, :opt?-postfix) + // are flagged as degraded for in-page matching. + const mixed = diagnostics.filter((d) => d.kind === 'mixed-segment').map((d) => d.path).sort(); + expect(mixed).toContain('/optional/prefix-:opt?'); + expect(mixed).toContain('/optional/:opt?-postfix'); + expect(mixed).toContain('/:sub?/route-:slug()'); + }); + + it('keeps required and optional variants of the same param distinct', () => { + const { byPath } = build([ + { name: 'foo', path: '/:foo()', file: `${pagesDir}/[foo].vue`, children: [] }, + { name: 'foo', path: '/:foo?', file: `${pagesDir}/[[foo]].vue`, children: [] }, + ]); + expect(byPath.has('/:foo()')).toBe(true); + expect(byPath.has('/:foo?')).toBe(true); + }); + + it('supports regex-constrained and special-char param names', () => { + const { manifest } = build([ + { name: 'a1_1a', path: '/:a1_1a()', file: `${pagesDir}/[a1_1a].vue`, children: [] }, + { name: 'c33c', path: '/:c33c?', file: `${pagesDir}/[[c3@3c]].vue`, children: [] }, + ]); + expect(matchSparklingRoute(manifest, '/hello')?.route.name).toBe('a1_1a'); + }); +}); + +describe('deeply nested routes collapse to one bundle per URL', () => { + it('index child wins the parent URL; the layout wrapper is flagged', () => { + const { byPath, diagnostics } = build([ + { + path: '/page1', + file: `${pagesDir}/page1.vue`, + children: [ + { name: 'page1-id', path: ':id()', file: `${pagesDir}/page1/[id].vue`, children: [] }, + { name: 'page1', path: '', file: `${pagesDir}/page1/index.vue`, children: [] }, + ], + }, + ]); + // /page1 resolves to the index child's bundle, not the wrapper's. + expect(byPath.get('/page1')?.bundle).toBe('page1.lynx.bundle'); + expect(byPath.get('/page1/:id()')?.bundle).toBe('page1-id.lynx.bundle'); + expect(diagnostics.some((d) => d.kind === 'nested-outlet' && d.path === '/page1')).toBe(true); + }); + + it('handles multi-level nested merges (param.vue + index/index + siblings)', () => { + const { byPath } = build([ + { + path: '/param', + file: `${pagesDir}/param.vue`, + children: [ + { + path: '', + file: `${layerDir}/param/index.vue`, + children: [ + { name: 'param-index', path: '', file: `${pagesDir}/param/index/index.vue`, children: [] }, + { name: 'param-index-sibling', path: 'sibling', file: `${layerDir}/param/index/sibling.vue`, children: [] }, + ], + }, + { name: 'param-sibling', path: 'sibling', file: `${pagesDir}/param/sibling.vue`, children: [] }, + ], + }, + ]); + expect(byPath.get('/param')?.bundle).toBe('param-index.lynx.bundle'); + expect(byPath.has('/param/sibling')).toBe(true); + }); +}); + +describe('route groups (URL-transparent)', () => { + it('drops the parenthesized folder from the URL and carries groups meta', () => { + const { byPath } = build([ + { name: 'index', path: '/', file: `${pagesDir}/(foo)/index.vue`, meta: { groups: ['foo'] }, children: [] }, + { + path: '/about', + file: `${pagesDir}/(foo)/about.vue`, + meta: { groups: ['foo'] }, + children: [ + { + path: '', + file: `${pagesDir}/(bar)/about/index.vue`, + meta: { groups: ['bar'] }, + children: [ + { name: 'about', path: '', file: `${pagesDir}/(bar)/about/(foo)/index.vue`, meta: { groups: ['bar', 'foo'] }, children: [] }, + ], + }, + ], + }, + ]); + expect(byPath.has('/')).toBe(true); + expect(byPath.has('/about')).toBe(true); + // deepest index child wins /about and carries merged groups + expect(byPath.get('/about')?.meta?.groups).toEqual(['bar', 'foo']); + }); +}); + +describe('unicode and special characters', () => { + it('preserves Nuxt-encoded unicode paths and matches them', () => { + const enc = encodeURIComponent('测试'); + const { byPath, manifest } = build([ + { name: '测试', path: `/${enc}`, file: `${pagesDir}/测试.vue`, children: [] }, + ]); + expect(byPath.has(`/${enc}`)).toBe(true); + expect(matchSparklingRoute(manifest, `/${enc}`)?.route.name).toBe('测试'); + }); +}); + +describe('redirects', () => { + it('encodes a static redirect in meta and flags it', () => { + const { byPath, diagnostics } = build([ + { name: 'home', path: '/old', file: `${pagesDir}/old.vue`, redirect: '/new', children: [] }, + ]); + expect(byPath.get('/old')?.meta?.redirect).toBe('/new'); + expect(diagnostics.some((d) => d.kind === 'redirect' && d.path === '/old')).toBe(true); + }); +}); diff --git a/packages/nuxt-sparkling/package.json b/packages/nuxt-sparkling/package.json new file mode 100644 index 00000000..30cf7bda --- /dev/null +++ b/packages/nuxt-sparkling/package.json @@ -0,0 +1,60 @@ +{ + "name": "nuxt-sparkling", + "version": "2.1.0-rc.12", + "description": "Nuxt module that drives Sparkling native navigation (MPA) from file-based pages, via web-navigation-shim", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/nuxt-sparkling" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./module": { + "types": "./dist/module.d.ts", + "default": "./dist/module.js" + }, + "./runtime/history": { + "types": "./dist/runtime/history.d.ts", + "default": "./dist/runtime/history.js" + } + }, + "files": [ + "src", + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "sparkling-navigation": "workspace:*", + "web-navigation-shim": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vitest": "^4.0.18", + "vue-router": "^5.1.0", + "@vue/runtime-core": "^3.5.13", + "vue": "^3.5.13" + }, + "peerDependencies": { + "vue-router": "^4.5.0 || ^5.0.0" + }, + "keywords": [ + "nuxt", + "lynx", + "sparkling", + "navigation", + "mpa" + ], + "license": "Apache-2.0" +} diff --git a/packages/nuxt-sparkling/src/index.ts b/packages/nuxt-sparkling/src/index.ts new file mode 100644 index 00000000..df989c31 --- /dev/null +++ b/packages/nuxt-sparkling/src/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { pagesToSparklingManifest } from './manifest'; +export type { + NuxtLikePage, + PagesToManifestOptions, + PagesToManifestResult, + ManifestDiagnostic, + DiagnosticKind, +} from './manifest'; +export { analyzePages, createNuxtSparklingModule } from './module'; +export type { NuxtSparklingOptions, BuildManifestReport } from './module'; +export { + sparklingHistory, + installSparklingShim, + sparklingScrollBehavior, +} from './runtime/history'; +export type { SparklingHistoryOptions } from './runtime/history'; diff --git a/packages/nuxt-sparkling/src/manifest.ts b/packages/nuxt-sparkling/src/manifest.ts new file mode 100644 index 00000000..7df00489 --- /dev/null +++ b/packages/nuxt-sparkling/src/manifest.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { + SparklingManifestRoute, + SparklingRouteManifest, +} from 'sparkling-navigation/shim-host'; + +/** + * Structural copy of Nuxt's `NuxtPage` (the shape handed to the + * `pages:extend` hook). Declared locally so this transform has no + * build-time dependency on Nuxt internals; every field Nuxt emits that + * we consume is represented here. + */ +export interface NuxtLikePage { + name?: string; + path: string; + file?: string; + /** vue-router named views — unsupported in MPA (single outlet per page). */ + meta?: Record & { groups?: string[] }; + alias?: string | string[]; + redirect?: unknown; + props?: unknown; + children?: NuxtLikePage[]; +} + +export interface PagesToManifestOptions { + origin?: string; + base?: string; + baseScheme?: string; + externalScheme?: string | null; + /** + * Map a page `file` to the rspeedy build entry name. Defaults to the + * page `name` (Nuxt guarantees uniqueness), falling back to a slug of + * the absolute path. + */ + entryForFile?(file: string, page: NuxtLikePage, absolutePath: string): string; +} + +export type DiagnosticKind = + | 'nested-outlet' + | 'named-view' + | 'redirect' + | 'mixed-segment' + | 'no-file' + | 'dynamic-deep-link'; + +export interface ManifestDiagnostic { + kind: DiagnosticKind; + /** Absolute route path the diagnostic concerns. */ + path: string; + /** Human-readable explanation, incl. whether it degrades or blocks. */ + message: string; + /** true = feature works with a caveat; false = feature cannot work. */ + degraded: boolean; +} + +export interface PagesToManifestResult { + manifest: SparklingRouteManifest; + diagnostics: ManifestDiagnostic[]; +} + +function slugify(path: string): string { + const cleaned = path + .replace(/^\//, '') + .replace(/[^\w]+/g, '-') + .replace(/^-+|-+$/g, ''); + return cleaned || 'index'; +} + +/** Join a relative Nuxt child path onto an absolute parent path. */ +function joinAbsolute(parentAbs: string, childPath: string): string { + if (childPath.startsWith('/')) return childPath; + if (childPath === '') return parentAbs; + const base = parentAbs === '/' ? '' : parentAbs.replace(/\/$/, ''); + return `${base}/${childPath}`; +} + +/** + * A segment mixing a literal with a param, or two params + * (e.g. `route-:slug()`, `:b2()_:2b()`, `:opt?-postfix`). The runtime + * matcher in sparkling-navigation only handles whole-segment params, so + * flag these — they still round-trip as native opens, but in-context + * matching of the param is not guaranteed. + */ +function isMixedSegment(path: string): boolean { + return path + .split('/') + .some((seg) => { + if (!seg.includes(':')) return false; + // Pure param segments: ":name", ":name()", ":name(re)", with a + // single trailing modifier and nothing else. + return !/^:[\w]+(\([^)]*\))?[?+*]?$/.test(seg); + }); +} + +interface Collected { + page: NuxtLikePage; + absolutePath: string; + groups: string[]; + /** true when this file is a layout wrapper around non-index children. */ + wrapper: boolean; +} + +/** + * Flatten Nuxt's nested page tree into a URL→page table. In an MPA every + * URL maps to exactly one bundle (isolated JS heap), so nested + * `` outlets collapse: the deepest page owning an absolute path + * wins (an index child overrides its parent for the same URL). + */ +function collect( + pages: NuxtLikePage[], + parentAbs: string, + table: Map, + diagnostics: ManifestDiagnostic[], +): void { + for (const page of pages) { + const absolutePath = joinAbsolute(parentAbs, page.path); + // Nuxt records the fully-resolved group list on each node's meta, so + // use it directly rather than accumulating from ancestors. + const groups = (page.meta?.groups as string[] | undefined) ?? []; + const children = page.children ?? []; + const hasIndexChild = children.some((c) => c.path === ''); + const nonIndexChildren = children.filter((c) => c.path !== ''); + + if (page.file) { + const existing = table.get(absolutePath); + // Index children (reached with path '') are more specific than the + // parent wrapper for the same URL — let them win. + const isIndexOfParent = page.path === '' && absolutePath === parentAbs; + if (!existing || isIndexOfParent) { + table.set(absolutePath, { page, absolutePath, groups, wrapper: false }); + } + + // A file with non-index children is a persistent layout wrapper. + // MPA cannot keep it mounted across child navigations (separate + // heaps), so mark it degraded — the wrapper must be duplicated into + // each child bundle or expressed as a native container. + if (nonIndexChildren.length > 0) { + diagnostics.push({ + kind: 'nested-outlet', + path: absolutePath, + degraded: true, + message: + `"${page.file}" wraps child routes via . In the MPA model each ` + + 'child opens as a separate native page (separate JS heap), so the wrapper is ' + + 'not kept mounted across them; duplicate shared layout into children or use a ' + + 'native container.', + }); + } + void hasIndexChild; + } + + if (isMixedSegment(absolutePath)) { + diagnostics.push({ + kind: 'mixed-segment', + path: absolutePath, + degraded: true, + message: + `Route "${absolutePath}" mixes literals and params within one segment; native ` + + 'navigation still works but in-page param matching of this route is best-effort.', + }); + } + + if (children.length) { + collect(children, absolutePath, table, diagnostics); + } + } +} + +function normalizeManifestPath(absolutePath: string): string { + if (absolutePath === '') return '/'; + return absolutePath; +} + +/** + * Transform a Nuxt page tree (as delivered to `pages:extend`) into a + * Sparkling route manifest for the file-based MPA model, plus a list of + * diagnostics describing anything that degrades or cannot cross JS heaps. + */ +export function pagesToSparklingManifest( + pages: NuxtLikePage[], + options: PagesToManifestOptions = {}, +): PagesToManifestResult { + const diagnostics: ManifestDiagnostic[] = []; + const table = new Map(); + collect(pages, options.base ?? '/', table, diagnostics); + + const entryFor = options.entryForFile + ?? ((_file: string, page: NuxtLikePage, absolutePath: string) => page.name ?? slugify(absolutePath)); + + const routes: SparklingManifestRoute[] = []; + for (const { page, absolutePath, groups } of table.values()) { + const entry = entryFor(page.file!, page, absolutePath); + const meta: Record = { ...(page.meta ?? {}) }; + if (groups.length) meta.groups = groups; + + if (page.redirect !== undefined) { + diagnostics.push({ + kind: 'redirect', + path: absolutePath, + degraded: true, + message: + `Route "${absolutePath}" declares a redirect. Static/string redirects are encoded ` + + 'in the manifest for the host to resolve; function redirects run only once the ' + + 'target page has booted.', + }); + meta.redirect = page.redirect; + } + + routes.push({ + name: page.name, + path: normalizeManifestPath(absolutePath), + entry, + bundle: `${entry}.lynx.bundle`, + ...(Object.keys(meta).length ? { meta } : {}), + }); + } + + // Emit routes in a stable, specificity-friendly order (static before + // dynamic is handled by the matcher; here sort by path for determinism). + routes.sort((a, b) => a.path.localeCompare(b.path)); + + const manifest: SparklingRouteManifest = { + version: 1, + origin: options.origin, + base: options.base, + baseScheme: options.baseScheme, + ...(options.externalScheme !== undefined ? { externalScheme: options.externalScheme } : {}), + routes, + }; + + return { manifest, diagnostics }; +} diff --git a/packages/nuxt-sparkling/src/module.ts b/packages/nuxt-sparkling/src/module.ts new file mode 100644 index 00000000..d20946b0 --- /dev/null +++ b/packages/nuxt-sparkling/src/module.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Nuxt module: `nuxt-sparkling/module`. + * + * Wires Nuxt's file-based routing to Sparkling native navigation (MPA): + * + * 1. Hooks `pages:extend` to transform the resolved page tree into a + * Sparkling route manifest (via {@link pagesToSparklingManifest}) and + * exposes it to runtime as the virtual module `#sparkling/route-manifest`. + * 2. Emits build diagnostics for anything that cannot cross JS heaps + * (nested outlets, mixed segments, redirects) so authors see, per the + * acceptance criteria, exactly which Nuxt features are supported / + * degraded / unsupported for their app. + * 3. Sets Nuxt options that are incompatible with the Lynx JS context off + * by default (no DOM chunk-reload, no app manifest network probe, etc.). + * + * This file is authored against `@nuxt/kit`'s public API but declares the + * few helpers it uses locally, so the package builds without Nuxt present + * (Nuxt is a peer/consumer concern). When run inside Nuxt, `defineNuxtModule` + * and friends are provided by the host. + */ + +import { pagesToSparklingManifest } from './manifest'; +import type { NuxtLikePage, ManifestDiagnostic, PagesToManifestOptions } from './manifest'; + +export interface NuxtSparklingOptions extends PagesToManifestOptions { + /** Router mode for the runtime history (see runtime/history). Default 'memory'. */ + mode?: 'web' | 'memory'; + /** Fail the build if any non-degraded (blocking) diagnostic is found. Default false. */ + strict?: boolean; + /** Print the per-feature support report at build time. Default true. */ + report?: boolean; +} + +/** + * Minimal structural surface of the pieces of `@nuxt/kit` / Nuxt we use. + * Declared locally to avoid a hard build dependency on Nuxt. + */ +interface NuxtKit { + defineNuxtModule(def: { + meta: { name: string; configKey: string }; + defaults?: Partial; + setup(options: T, nuxt: NuxtLike): void | Promise; + }): unknown; + addTemplate(opts: { filename: string; getContents(): string; write?: boolean }): { dst: string }; + addTypeTemplate(opts: { filename: string; getContents(): string }): unknown; + createResolver(url: string): { resolve(...p: string[]): string }; + logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void }; +} + +interface NuxtLike { + options: { + alias?: Record; + experimental?: Record; + [k: string]: unknown; + }; + hook(name: 'pages:extend', cb: (pages: NuxtLikePage[]) => void): void; + hook(name: string, cb: (...args: unknown[]) => void): void; +} + +export interface BuildManifestReport { + routeCount: number; + diagnostics: ManifestDiagnostic[]; + supported: string[]; + degraded: string[]; + blocked: string[]; +} + +/** + * Pure helper (also used by tests): produce the manifest + a + * feature-classification report from a page tree. + */ +export function analyzePages( + pages: NuxtLikePage[], + options: NuxtSparklingOptions = {}, +): { manifest: ReturnType['manifest']; report: BuildManifestReport } { + const { manifest, diagnostics } = pagesToSparklingManifest(pages, options); + const degraded = diagnostics.filter((d) => d.degraded).map((d) => `${d.kind} @ ${d.path}`); + const blocked = diagnostics.filter((d) => !d.degraded).map((d) => `${d.kind} @ ${d.path}`); + return { + manifest, + report: { + routeCount: manifest.routes.length, + diagnostics, + supported: manifest.routes.map((r) => r.path), + degraded, + blocked, + }, + }; +} + +/** + * The Nuxt module factory. Call `createNuxtSparklingModule(kit)` with the + * host's `@nuxt/kit` exports, or import the default from + * `nuxt-sparkling/module` inside a Nuxt project (where the build resolves + * `@nuxt/kit` for you). + */ +export function createNuxtSparklingModule(kit: NuxtKit): unknown { + return kit.defineNuxtModule({ + meta: { name: 'nuxt-sparkling', configKey: 'sparkling' }, + defaults: { mode: 'memory', strict: false, report: true }, + setup(options, nuxt) { + // Disable browser-only Nuxt subsystems that would throw or hang in a + // Lynx JS context (see docs/nuxt-web-api-dependencies). + nuxt.options.experimental = { + ...nuxt.options.experimental, + emitRouteChunkError: false, + appManifest: false, + navigationRepaint: false, + restoreState: false, + }; + + nuxt.hook('pages:extend', (pages) => { + const { manifest, report } = analyzePages(pages, options); + + kit.addTemplate({ + filename: 'sparkling/route-manifest.mjs', + write: true, + getContents: () => `export default ${JSON.stringify(manifest, null, 2)}\n`, + }); + kit.addTypeTemplate({ + filename: 'sparkling/route-manifest.d.ts', + getContents: () => + 'import type { SparklingRouteManifest } from \'sparkling-navigation/shim-host\'\n' + + 'declare const manifest: SparklingRouteManifest\n' + + 'export default manifest\n', + }); + nuxt.options.alias ??= {}; + (nuxt.options.alias as Record)['#sparkling/route-manifest'] + = '#build/sparkling/route-manifest.mjs'; + + if (options.report !== false) { + kit.logger.info( + `[nuxt-sparkling] ${report.routeCount} route(s) → manifest. ` + + `${report.degraded.length} degraded, ${report.blocked.length} blocked.`, + ); + for (const d of report.diagnostics) { + const line = `[nuxt-sparkling] ${d.degraded ? 'degraded' : 'BLOCKED'}: ${d.kind} @ ${d.path} — ${d.message}`; + if (d.degraded) kit.logger.warn(line); + else kit.logger.error(line); + } + } + + if (options.strict && report.blocked.length) { + throw new Error( + `[nuxt-sparkling] ${report.blocked.length} blocking route issue(s) in strict mode:\n` + + report.blocked.join('\n'), + ); + } + }); + }, + }); +} diff --git a/packages/nuxt-sparkling/src/runtime/history.ts b/packages/nuxt-sparkling/src/runtime/history.ts new file mode 100644 index 00000000..eb897423 --- /dev/null +++ b/packages/nuxt-sparkling/src/runtime/history.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Runtime glue for `app/router.options.ts`: + * + * import { sparklingHistory, sparklingScrollBehavior } from 'nuxt-sparkling/runtime/history' + * import manifest from '#sparkling/route-manifest' + * export default { + * history: () => sparklingHistory(manifest), + * scrollBehavior: sparklingScrollBehavior, + * } + * + * `sparklingHistory` installs the web-navigation-shim (backed by the + * Sparkling NavigationHost) onto the current JS context, then returns a + * vue-router history bound to it. Cross-page `router.push`/`navigateTo`/ + * `` navigations become native `router.open` calls; back at the + * bottom of the in-page stack becomes `router.close`. + */ + +import { createNavigationShim } from 'web-navigation-shim'; +import type { NavigationShim } from 'web-navigation-shim'; +import { createSparklingNavigationHost } from 'sparkling-navigation/shim-host'; +import type { SparklingRouteManifest } from 'sparkling-navigation/shim-host'; + +// Loose vue-router typings so we do not hard-pin a major version. +type RouterHistory = unknown; +type HistoryFactory = (base?: string) => RouterHistory; + +export interface SparklingHistoryOptions { + /** + * Which vue-router history to layer on top of the shim. + * - `'web'` (default): full History API — supports in-page back/forward + * and hash navigation within a bundle; cross-bundle navigations still + * hand off to the native host. Use for SPA-inside-MPA bundles. + * - `'memory'`: one route per JS context (pure MPA). Lightest; every + * cross-path navigation is a native open. Recommended default for + * one-page-per-bundle apps. + */ + mode?: 'web' | 'memory'; + /** + * Provide vue-router's history factories. Kept as a parameter so this + * module has no hard dependency on a specific vue-router version; + * `router.options.ts` passes them in (or the module's generated glue + * does). Defaults to a dynamic import of `vue-router`. + */ + factories?: { + createWebHistory?: HistoryFactory; + createMemoryHistory?: HistoryFactory; + }; + /** Widen same-document identity to same-bundle (SPA-inside-MPA). */ + sameBundleIsSameDocument?: boolean; +} + +let installedShim: NavigationShim | undefined; + +/** + * Install the shim (idempotent per JS context) and return the shim so the + * caller can build the vue-router history from its `location`. + */ +export function installSparklingShim( + manifest: SparklingRouteManifest, + options: SparklingHistoryOptions = {}, +): NavigationShim { + if (installedShim) return installedShim; + const host = createSparklingNavigationHost({ + manifest, + sameBundleIsSameDocument: options.sameBundleIsSameDocument, + }); + const shim = createNavigationShim(host); + // Define only-missing globals; on the web dev harness the real browser + // globals stay in place (the shim never clobbers them). + shim.install(globalThis as unknown as Record); + installedShim = shim; + return shim; +} + +/** + * Build a vue-router history bound to the Sparkling-backed shim. + * The returned value is passed straight to `createRouter({ history })`. + */ +export async function sparklingHistory( + manifest: SparklingRouteManifest, + options: SparklingHistoryOptions = {}, +): Promise { + const shim = installSparklingShim(manifest, options); + const mode = options.mode ?? 'memory'; + + let createWebHistory = options.factories?.createWebHistory; + let createMemoryHistory = options.factories?.createMemoryHistory; + if (!createWebHistory || !createMemoryHistory) { + const vr = (await import('vue-router')) as { + createWebHistory: HistoryFactory; + createMemoryHistory: HistoryFactory; + }; + createWebHistory ??= vr.createWebHistory; + createMemoryHistory ??= vr.createMemoryHistory; + } + + const base = manifest.base ?? '/'; + if (mode === 'web') { + return createWebHistory!(base); + } + // Memory history seeded at this page's initial URL, so the in-context + // router boots directly at the deep-linked route. + const memory = createMemoryHistory!(base); + const path = shim.location.pathname + shim.location.search + shim.location.hash; + // vue-router memory history starts at '/'; the Nuxt router plugin will + // replace to the initial URL during app:created using shim.location. + void path; + return memory; +} + +/** + * Recommended scrollBehavior for Lynx: return false so vue-router never + * touches window.scrollTo / documentElement (there is no DOM viewport; + * native containers own scrolling). + */ +export function sparklingScrollBehavior(): false { + return false; +} + +/** Test seam: forget the installed shim. */ +export function __resetSparklingShim(): void { + installedShim = undefined; +} diff --git a/packages/nuxt-sparkling/tsconfig.json b/packages/nuxt-sparkling/tsconfig.json new file mode 100644 index 00000000..d34245a4 --- /dev/null +++ b/packages/nuxt-sparkling/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": false, + "lib": ["ES2020", "DOM"], + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler" + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/__tests__/**" + ] +} diff --git a/packages/nuxt-sparkling/vitest.config.ts b/packages/nuxt-sparkling/vitest.config.ts new file mode 100644 index 00000000..ca9db79c --- /dev/null +++ b/packages/nuxt-sparkling/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + // vue-router only needs @vue/runtime-core; the full `vue` build + // touches document.createElement at import (no DOM in Lynx/Node). + vue: '@vue/runtime-core', + }, + }, + test: { + environment: 'node', + include: ['__tests__/**/*.spec.ts'], + server: { + deps: { + // Inline these so vi.mock('sparkling-method') applies through the + // prebuilt dist and vue-router picks up the `vue` alias. + inline: ['vue-router', 'sparkling-navigation', 'sparkling-method', 'web-navigation-shim'], + }, + }, + }, +}); diff --git a/packages/playground/lynx.config.ts b/packages/playground/lynx.config.ts index a07a2ebb..d39cfabf 100644 --- a/packages/playground/lynx.config.ts +++ b/packages/playground/lynx.config.ts @@ -6,7 +6,7 @@ import path from 'path' import { defineConfig } from '@lynx-js/rspeedy' import lynxSharedConfig from './lynx.shared.config.js' -function copyDir(src: string, dest: string) { +function copyDir(src: string, dest: string, filter?: (name: string) => boolean) { if (!fs.existsSync(src)) { console.warn(`Source directory ${src} does not exist, skipping copy`) return @@ -16,11 +16,15 @@ function copyDir(src: string, dest: string) { const entries = fs.readdirSync(src, { withFileTypes: true }) for (const entry of entries) { + if (filter && !filter(entry.name)) { + continue + } + const srcPath = path.join(src, entry.name) const destPath = path.join(dest, entry.name) if (entry.isDirectory()) { - copyDir(srcPath, destPath) + copyDir(srcPath, destPath, filter) } else { fs.copyFileSync(srcPath, destPath) } @@ -32,6 +36,21 @@ export default defineConfig({ server: { port: 5969, }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: { + output: { + assetPrefix: 'asset:///', + }, + }, + }, plugins: [ ...(lynxSharedConfig.plugins ?? []), { @@ -42,11 +61,14 @@ export default defineConfig({ const androidDest = 'android/app/src/main/assets' const iosDest = 'ios/LynxResources' + // Skip the web subdirectory when copying to native asset dirs + const nativeFilter = (name: string) => name !== 'web' + console.log(`Copying ${sourceDir} to Android (${androidDest})...`) - copyDir(sourceDir, androidDest) + copyDir(sourceDir, androidDest, nativeFilter) console.log(`Copying ${sourceDir} to iOS (${iosDest})...`) - copyDir(sourceDir, iosDest) + copyDir(sourceDir, iosDest, nativeFilter) console.log('Assets copied successfully!') }) diff --git a/packages/playground/package.json b/packages/playground/package.json index 3ebd646e..1ed74121 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -11,7 +11,9 @@ }, "scripts": { "build": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-storage --filter sparkling-media build && rspeedy build", + "build:web": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-storage --filter sparkling-media build && rspeedy build --environment web", "dev": "rspeedy dev", + "dev:web": "rspeedy build --environment web && pnpm --filter sparkling-web-shell dev", "format": "prettier --write .", "preview": "rspeedy preview", "test": "vitest run --passWithNoTests", diff --git a/packages/sparkling-app-cli/src/commands/autolink.ts b/packages/sparkling-app-cli/src/commands/autolink.ts index 1f174292..02b635b5 100644 --- a/packages/sparkling-app-cli/src/commands/autolink.ts +++ b/packages/sparkling-app-cli/src/commands/autolink.ts @@ -20,7 +20,7 @@ const IOS_AUTOLINK_END = '# END SPARKLING AUTOLINK'; export interface AutolinkOptions { cwd: string; configFile?: string; - platform?: 'android' | 'ios' | 'all'; + platform?: 'android' | 'ios' | 'web' | 'all'; } /** @@ -256,6 +256,8 @@ async function discoverModules(cwd: string): Promise { const androidMavenDependency = isNodeModule ? resolveMavenDependency(androidConfig?.mavenDependency, moduleRoot) : undefined; + const webConfig = config.web as Record | undefined; + const platforms = Array.isArray(config.platforms) ? config.platforms as string[] : undefined; const androidBuild = (androidConfig?.buildGradle && typeof androidConfig.buildGradle === 'string') ? path.resolve(moduleRoot, androidConfig.buildGradle) @@ -276,6 +278,7 @@ async function discoverModules(cwd: string): Promise { name, root: moduleRoot, devtool: config.devtool === true, + platforms, android: { packageName: androidPackageName, className: androidClassName, @@ -293,6 +296,10 @@ async function discoverModules(cwd: string): Promise { className: typeof iosConfig?.className === 'string' ? iosConfig.className : undefined, podspecPath: iosPodspecPath, }, + web: webConfig ? { + entryPoint: typeof webConfig.entryPoint === 'string' ? webConfig.entryPoint : undefined, + subpath: typeof webConfig.subpath === 'string' ? webConfig.subpath : './web', + } : undefined, }); } } @@ -984,14 +991,50 @@ function writeIosRegistry(modules: MethodModuleConfig[], bundleId: string, cwd: fs.writeFileSync(filePath, content); } +function writeWebAutolink(modules: MethodModuleConfig[], cwd: string) { + const tempDir = path.resolve(cwd, '.sparkling'); + fs.ensureDirSync(tempDir); + const filePath = path.join(tempDir, 'web-autolink.ts'); + + const webModules = modules.filter(m => + (m.platforms && m.platforms.includes('web')) || m.web, + ); + const skippedModules = modules.filter(m => + !(m.platforms && m.platforms.includes('web')) && !m.web, + ); + + const lines: string[] = [ + '// Generated by sparkling autolink — do not edit', + ]; + + for (const mod of webModules) { + const subpath = mod.web?.subpath ?? './web'; + const importPath = `${mod.name}/${subpath.replace(/^\.\//, '')}`; + lines.push(`import '${importPath}';`); + } + + for (const mod of skippedModules) { + lines.push(`// ${mod.name}: web platform not supported, skipping`); + } + + lines.push(''); + fs.writeFileSync(filePath, lines.join('\n')); + + if (isVerboseEnabled()) { + verboseLog(`Web autolink: ${webModules.length} module(s) linked, ${skippedModules.length} skipped`); + verboseLog(`Web autolink written to ${filePath}`); + } +} + export async function autolink(options: AutolinkOptions): Promise { const platform = options.platform ?? 'all'; const doAndroid = platform === 'android' || platform === 'all'; const doIos = platform === 'ios' || platform === 'all'; + const doWeb = platform === 'web' || platform === 'all'; let modules = await discoverModules(options.cwd); if (isVerboseEnabled()) { const moduleNames = modules.map(m => m.name).join(', ') || '(none)'; - verboseLog(`Autolink platforms -> android: ${doAndroid}, ios: ${doIos}`); + verboseLog(`Autolink platforms -> android: ${doAndroid}, ios: ${doIos}, web: ${doWeb}`); verboseLog(`Autolink discovered modules: ${moduleNames}`); } // Prefer user-defined IDs but fall back to defaults to stay compatible even if config can't load. @@ -1069,7 +1112,15 @@ export async function autolink(options: AutolinkOptions): Promise { const configPath = path.resolve(options.cwd, options.configFile ?? 'app.config.ts'); const tempConfigPath = createTempLynxConfig(options.cwd, configPath); - const rspeedyBin = 'rspeedy'; + const platform = options.platform ?? 'all'; if (isVerboseEnabled()) { verboseLog(`App config path: ${configPath}`); verboseLog(`Temp Lynx config: ${tempConfigPath}`); - verboseLog(`rspeedy binary: ${rspeedyBin}`); + verboseLog(`Build platform: ${platform}`); + } + + const rspeedyArgs = ['build', '--config', tempConfigPath]; + + if (platform === 'web') { + rspeedyArgs.push('--environment', 'web'); + } else if (platform === 'android' || platform === 'ios' || platform === 'native') { + rspeedyArgs.push('--environment', 'lynx'); } + // platform === 'all' → no --environment flag → builds all environments - console.log(ui.headline(`Building Lynx bundle with config from ${path.relative(options.cwd, configPath)}`)); - await runCommand(rspeedyBin, ['build', '--config', tempConfigPath], { cwd: options.cwd }); + console.log(ui.headline(`Building ${platform} bundle(s) with config from ${path.relative(options.cwd, configPath)}`)); + await runCommand('rspeedy', rspeedyArgs, { cwd: options.cwd }); - const shouldCopy = options.skipCopy !== true; // default to no copy + // Skip asset copy for web-only builds + const shouldCopy = platform !== 'web' && options.skipCopy !== true; if (shouldCopy) { - // Read AppConfig to locate platform asset destinations if provided const { config } = await loadAppConfig(options.cwd, options.configFile ?? 'app.config.ts'); await copyAssets({ cwd: options.cwd, androidDest: config.paths?.androidAssets ?? 'android/app/src/main/assets', iosDest: config.paths?.iosAssets ?? 'ios/LynxResources/Assets', + // When building all environments, exclude the web/ subdirectory from native copies + excludeDirs: platform === 'all' ? ['web'] : undefined, }); } else if (isVerboseEnabled()) { - verboseLog('Skipping asset copy because --skip-copy is in effect.'); + verboseLog(platform === 'web' + ? 'Skipping asset copy for web-only build.' + : 'Skipping asset copy because --skip-copy is in effect.'); } } diff --git a/packages/sparkling-app-cli/src/commands/copy-assets.ts b/packages/sparkling-app-cli/src/commands/copy-assets.ts index f5ef6f97..72ce685a 100644 --- a/packages/sparkling-app-cli/src/commands/copy-assets.ts +++ b/packages/sparkling-app-cli/src/commands/copy-assets.ts @@ -11,16 +11,46 @@ export interface CopyAssetsOptions { source?: string; androidDest?: string; iosDest?: string; + webDest?: string; + /** Directory names to exclude from native (android/ios) copies (e.g., ['web']) */ + excludeDirs?: string[]; cwd: string; } -function copyDir(src: string, dest: string) { +function copyDir(src: string, dest: string, excludeDirs?: string[]) { if (!fs.existsSync(src)) { console.warn(ui.warn(`Skip copy: missing source ${src}`)); return; } fs.ensureDirSync(dest); - fs.cpSync(src, dest, { recursive: true, force: true, dereference: true }); + + if (excludeDirs?.length) { + copyDirFiltered(src, dest, excludeDirs); + } else { + fs.cpSync(src, dest, { recursive: true, force: true, dereference: true }); + } +} + +function copyDirFiltered(src: string, dest: string, excludeDirs: string[]) { + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && excludeDirs.includes(entry.name)) { + if (isVerboseEnabled()) { + verboseLog(`Excluding directory ${entry.name} from native copy`); + } + continue; + } + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirFiltered(srcPath, destPath, excludeDirs); + } else { + fs.ensureDirSync(path.dirname(destPath)); + fs.copyFileSync(srcPath, destPath); + } + } } export async function copyAssets(options: CopyAssetsOptions): Promise { @@ -31,11 +61,21 @@ export async function copyAssets(options: CopyAssetsOptions): Promise { verboseLog(`Copy assets source: ${source}`); verboseLog(`Android assets destination: ${androidDest}`); verboseLog(`iOS assets destination: ${iosDest}`); + if (options.excludeDirs?.length) { + verboseLog(`Excluding directories: ${options.excludeDirs.join(', ')}`); + } } for (const dest of [androidDest, iosDest]) { console.log(ui.info(`Copying ${relativeTo(options.cwd, source)} -> ${relativeTo(options.cwd, dest)}`)); - copyDir(source, dest); + copyDir(source, dest, options.excludeDirs); + } + + if (options.webDest) { + const webDest = path.resolve(options.cwd, options.webDest); + const webSource = path.resolve(source, 'web'); + console.log(ui.info(`Copying ${relativeTo(options.cwd, webSource)} -> ${relativeTo(options.cwd, webDest)}`)); + copyDir(webSource, webDest); } console.log(ui.success('Assets copied successfully.')); diff --git a/packages/sparkling-app-cli/src/commands/dev.ts b/packages/sparkling-app-cli/src/commands/dev.ts index 9112ec9a..547d9085 100644 --- a/packages/sparkling-app-cli/src/commands/dev.ts +++ b/packages/sparkling-app-cli/src/commands/dev.ts @@ -16,11 +16,14 @@ import { ui } from '../utils/ui'; import { isVerboseEnabled, verboseLog } from '../utils/verbose'; import { warnIfWildcardDevServerHost } from '../utils/dev-server-host'; +export type DevPlatform = 'web' | 'native' | 'all'; + export interface DevOptions { cwd: string; configFile?: string; port?: number; host?: string; + platform?: DevPlatform; } function getEntryKeys(config: { lynxConfig: unknown }): string[] { @@ -43,6 +46,7 @@ function clearConfigRequireCache(cwd: string, configPath: string): void { export async function devProject(options: DevOptions): Promise { const configFile = options.configFile ?? 'app.config.ts'; + const platform = options.platform ?? 'all'; const { config, configPath } = await loadAppConfig(options.cwd, configFile); const { devPort, lynxPort } = getConfiguredDevServerPorts(config); if (devPort !== undefined && lynxPort !== undefined && devPort !== lynxPort) { @@ -70,13 +74,22 @@ export async function devProject(options: DevOptions): Promise { if (host) { verboseLog(`Dev server host: ${host}`); } + verboseLog(`Dev platform: ${platform}`); + } + + const environmentArgs: string[] = []; + if (platform === 'web') { + environmentArgs.push('--environment', 'web'); + } else if (platform === 'native') { + environmentArgs.push('--environment', 'lynx'); } + // platform === 'all' → no --environment flag → serves all environments let currentEntryKeys = getEntryKeys(config); console.log( ui.headline( - `Starting Rspeedy dev server on port ${port} with config from ${path.relative(options.cwd, configPath)}`, + `Starting Rspeedy dev server (${platform}) on port ${port} with config from ${path.relative(options.cwd, configPath)}`, ), ); @@ -124,7 +137,7 @@ export async function devProject(options: DevOptions): Promise { if (isVerboseEnabled()) { verboseLog(`Temp Lynx config: ${tempConfigPath}`); } - child = spawn('rspeedy', ['dev', '--config', tempConfigPath], { + child = spawn('rspeedy', ['dev', '--config', tempConfigPath, ...environmentArgs], { cwd: options.cwd, env: process.env, stdio: 'inherit', diff --git a/packages/sparkling-app-cli/src/commands/doctor/checks.ts b/packages/sparkling-app-cli/src/commands/doctor/checks.ts index c8af4330..54dfd30a 100644 --- a/packages/sparkling-app-cli/src/commands/doctor/checks.ts +++ b/packages/sparkling-app-cli/src/commands/doctor/checks.ts @@ -348,6 +348,58 @@ export function checkCocoaPods(): CheckResult { return { ...base, status: 'pass', version }; } +export function checkWebCore(): CheckResult { + const base: Omit = { + name: '@lynx-js/web-core', + category: 'web', + }; + + try { + require.resolve('@lynx-js/web-core', { paths: [process.cwd()] }); + return { ...base, status: 'pass', message: 'Installed' }; + } catch { + return { + ...base, + status: 'fail', + message: '@lynx-js/web-core is not installed', + fixHint: + '@lynx-js/web-core is not installed. Install it with: pnpm add -D @lynx-js/web-core @lynx-js/web-elements', + }; + } +} + +export function checkWebShell(): CheckResult { + const base: Omit = { + name: 'sparkling-web-shell', + category: 'web', + }; + + try { + require.resolve('sparkling-web-shell/package.json', { paths: [process.cwd()] }); + return { ...base, status: 'pass', message: 'Installed' }; + } catch { + // Also check common workspace sibling locations + const cwd = process.cwd(); + const candidates = [ + path.join(cwd, '../sparkling-web-shell/package.json'), + path.join(cwd, '../../packages/sparkling-web-shell/package.json'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { ...base, status: 'pass', message: 'Found in workspace' }; + } + } + + return { + ...base, + status: 'fail', + message: 'sparkling-web-shell is not installed or found in workspace', + fixHint: + 'sparkling-web-shell is not found. Install it with: pnpm add -D sparkling-web-shell', + }; + } +} + export function checkSimulator(): CheckResult { const base: Omit = { name: 'iOS Simulator', diff --git a/packages/sparkling-app-cli/src/commands/doctor/index.ts b/packages/sparkling-app-cli/src/commands/doctor/index.ts index 4f54fe72..273688d6 100644 --- a/packages/sparkling-app-cli/src/commands/doctor/index.ts +++ b/packages/sparkling-app-cli/src/commands/doctor/index.ts @@ -13,6 +13,8 @@ import { checkXcode, checkCocoaPods, checkSimulator, + checkWebCore, + checkWebShell, } from './checks'; import type { CheckResult } from './types'; @@ -59,7 +61,7 @@ function buildAgentPrompt(failed: CheckResult[]): string { } export interface DoctorOptions { - platform: 'android' | 'ios' | 'all'; + platform: 'android' | 'ios' | 'web' | 'all'; } export async function doctor(opts: DoctorOptions): Promise { @@ -104,7 +106,19 @@ export async function doctor(opts: DoctorOptions): Promise { } } - const allResults = [...generalResults, ...androidResults, ...iosResults]; + const webResults: CheckResult[] = []; + if (platform === 'web' || platform === 'all') { + webResults.push(checkWebCore()); + webResults.push(checkWebShell()); + + console.log(''); + console.log(ui.info('Web:')); + for (const r of webResults) { + console.log(formatCheckLine(r)); + } + } + + const allResults = [...generalResults, ...androidResults, ...iosResults, ...webResults]; const failed = allResults.filter((r) => r.status === 'fail'); const warned = allResults.filter((r) => r.status === 'warn'); diff --git a/packages/sparkling-app-cli/src/commands/doctor/types.ts b/packages/sparkling-app-cli/src/commands/doctor/types.ts index d7c971a8..33d8a62e 100644 --- a/packages/sparkling-app-cli/src/commands/doctor/types.ts +++ b/packages/sparkling-app-cli/src/commands/doctor/types.ts @@ -8,7 +8,7 @@ export interface CheckResult { /** Display name of the check (e.g. "Node.js", "JDK") */ name: string; /** Category for grouping in output */ - category: 'general' | 'android' | 'ios'; + category: 'general' | 'android' | 'ios' | 'web'; /** Whether the check passed */ status: CheckStatus; /** Detected version string, if applicable */ diff --git a/packages/sparkling-app-cli/src/commands/run-web.ts b/packages/sparkling-app-cli/src/commands/run-web.ts new file mode 100644 index 00000000..d6311a2f --- /dev/null +++ b/packages/sparkling-app-cli/src/commands/run-web.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2025 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import path from 'node:path'; +import fs from 'fs-extra'; +import { WEB_SHELL_PORT } from '../constants'; +import { buildProject } from './build'; +import { autolink } from './autolink'; +import { runCommand } from '../utils/exec'; +import { ui } from '../utils/ui'; +import { isVerboseEnabled, verboseLog } from '../utils/verbose'; + +export interface RunWebOptions { + cwd: string; + configFile?: string; + port?: number; + open?: boolean; +} + +/** + * Locate the sparkling-web-shell package. + * Checks node_modules resolution first, then common workspace locations. + */ +function resolveWebShellDir(cwd: string): string | null { + // Try require.resolve to find the package entry + try { + const pkgJsonPath = require.resolve('sparkling-web-shell/package.json', { + paths: [cwd], + }); + return path.dirname(pkgJsonPath); + } catch { + // Not found via node resolution + } + + // Fallback: check common workspace sibling locations + const candidates = [ + path.resolve(cwd, '../sparkling-web-shell'), + path.resolve(cwd, '../../packages/sparkling-web-shell'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + + return null; +} + +export async function runWeb(options: RunWebOptions): Promise { + const cwd = options.cwd; + const port = options.port ?? WEB_SHELL_PORT; + const shouldOpen = options.open !== false; + + // Step 1: Run web autolink to generate web method imports + console.log(ui.headline('Autolinking web method modules...')); + await autolink({ cwd, platform: 'web', configFile: options.configFile }); + + // Step 2: Build web bundles only + console.log(ui.headline('Building web bundles...')); + await buildProject({ + cwd, + configFile: options.configFile, + platform: 'web', + skipCopy: true, + }); + + // Step 3: Resolve sparkling-web-shell location + const webShellDir = resolveWebShellDir(cwd); + if (!webShellDir) { + throw new Error( + 'Could not find sparkling-web-shell. Install it with:\n' + + ' pnpm add -D sparkling-web-shell', + ); + } + + if (isVerboseEnabled()) { + verboseLog(`Web shell resolved at: ${webShellDir}`); + } + + // Step 4: Verify build output exists + const distDir = path.resolve(cwd, 'dist/web'); + if (!fs.existsSync(distDir)) { + throw new Error(`Web build output not found at ${distDir}. Did the build succeed?`); + } + + // Step 5: Start the web shell dev server pointing at app's web dist + console.log(ui.headline(`Starting web preview at http://localhost:${port}`)); + + await runCommand('npx', ['rsbuild', 'dev'], { + cwd: webShellDir, + env: { + LYNX_BUNDLE_DIR: distDir, + PORT: String(port), + BROWSER: shouldOpen ? 'true' : 'none', + }, + }); +} diff --git a/packages/sparkling-app-cli/src/constants.ts b/packages/sparkling-app-cli/src/constants.ts index 73687cc4..f10da475 100644 --- a/packages/sparkling-app-cli/src/constants.ts +++ b/packages/sparkling-app-cli/src/constants.ts @@ -7,3 +7,9 @@ * 5969 = "LYNX" on a phone keypad (L=5, Y=9, N=6, X=9). */ export const DEV_SERVER_PORT = 5969; + +/** + * Default port for the web shell preview server. + * Matches the default in sparkling-web-shell/rsbuild.config.ts. + */ +export const WEB_SHELL_PORT = 4200; diff --git a/packages/sparkling-app-cli/src/index.ts b/packages/sparkling-app-cli/src/index.ts index d2660f78..9857daa9 100644 --- a/packages/sparkling-app-cli/src/index.ts +++ b/packages/sparkling-app-cli/src/index.ts @@ -6,11 +6,14 @@ import { Command } from 'commander'; import path from 'node:path'; import { autolink } from './commands/autolink'; import { buildProject } from './commands/build'; +import type { BuildPlatform } from './commands/build'; import { copyAssets } from './commands/copy-assets'; import { devProject } from './commands/dev'; +import type { DevPlatform } from './commands/dev'; import { doctor } from './commands/doctor'; import { runAndroid } from './commands/run-android'; import { runIos } from './commands/run-ios'; +import { runWeb } from './commands/run-web'; import { DEV_SERVER_PORT } from './constants'; import { ui } from './utils/ui'; import { enableVerboseLogging, isVerboseEnabled, verboseLog } from './utils/verbose'; @@ -41,22 +44,34 @@ function resolveSkipCopy(opts: { copy?: boolean; skipCopy?: boolean }): boolean return true; } +const BUILD_PLATFORMS = ['android', 'ios', 'web', 'native', 'all']; +const DEV_PLATFORMS = ['web', 'native', 'all']; +const AUTOLINK_PLATFORMS = ['android', 'ios', 'web', 'all']; +const DOCTOR_PLATFORMS = ['android', 'ios', 'web', 'all']; + program .command('build') .description('Build Lynx bundle using app.config.ts (no-copy by default)') .option('--config ', 'Path to app.config.ts', 'app.config.ts') + .option('--platform ', 'Target platform: android|ios|web|native|all', 'all') .option('--copy', 'Copy assets to native shells') .option('--skip-copy', 'Skip copying assets to native shells') .action(async opts => { const cwd = process.cwd(); const skipCopy = resolveSkipCopy(opts); - await buildProject({ cwd, configFile: opts.config, skipCopy }); + const raw = String(opts.platform ?? 'all').toLowerCase(); + const platform = (BUILD_PLATFORMS.includes(raw) ? raw : 'all') as BuildPlatform; + if (!BUILD_PLATFORMS.includes(raw)) { + console.warn(ui.warn(`Unknown platform "${opts.platform}", defaulting to 'all'.`)); + } + await buildProject({ cwd, configFile: opts.config, skipCopy, platform }); }); program .command('dev') .description('Start Lynx dev server with HMR using app.config.ts') .option('--config ', 'Path to app.config.ts', 'app.config.ts') + .option('--platform ', 'Target platform: web|native|all', 'all') .option('--port ', `Dev server port (default: app.config.ts dev.server.port or ${DEV_SERVER_PORT})`) .option('--host ', 'Dev server host (e.g. 127.0.0.1)') .action(async opts => { @@ -65,11 +80,17 @@ program if (opts.port !== undefined && !Number.isFinite(rawPort)) { throw new Error(`Invalid --port value: ${opts.port}`); } + const raw = String(opts.platform ?? 'all').toLowerCase(); + const platform = (DEV_PLATFORMS.includes(raw) ? raw : 'all') as DevPlatform; + if (!DEV_PLATFORMS.includes(raw)) { + console.warn(ui.warn(`Unknown platform "${opts.platform}", defaulting to 'all'.`)); + } await devProject({ cwd, configFile: opts.config, port: rawPort, host: opts.host, + platform, }); }); @@ -79,6 +100,7 @@ program .option('--source ', 'Path to compiled assets', 'dist') .option('--android-dest ', 'Android asset destination', 'android/app/src/main/assets') .option('--ios-dest ', 'iOS asset destination', 'ios/LynxResources/Assets') + .option('--exclude-web', 'Exclude web/ subdirectory from native copies') .action(async opts => { const cwd = process.cwd(); await copyAssets({ @@ -86,19 +108,19 @@ program source: opts.source, androidDest: opts.androidDest, iosDest: opts.iosDest, + excludeDirs: opts.excludeWeb ? ['web'] : undefined, }); }); program .command('autolink') - .description('Autolink Sparkling method modules for Android and iOS') - .option('--platform ', 'Platform to autolink: android|ios|all', 'all') + .description('Autolink Sparkling method modules for Android, iOS, and Web') + .option('--platform ', 'Platform to autolink: android|ios|web|all', 'all') .action(async (opts) => { const cwd = process.cwd(); const raw = String(opts.platform ?? 'all').toLowerCase(); - const allowed = ['android', 'ios', 'all']; - const platform = (allowed.includes(raw) ? raw : 'all') as 'android' | 'ios' | 'all'; - if (!allowed.includes(raw)) { + const platform = (AUTOLINK_PLATFORMS.includes(raw) ? raw : 'all') as 'android' | 'ios' | 'web' | 'all'; + if (!AUTOLINK_PLATFORMS.includes(raw)) { console.warn(ui.warn(`Unknown platform "${opts.platform}", defaulting to 'all'.`)); } await autolink({ cwd, platform }); @@ -136,15 +158,30 @@ program }); }); +program + .command('run:web') + .description('Build web bundles and launch in browser') + .option('--config ', 'Path to app.config.ts', 'app.config.ts') + .option('--port ', 'Web server port (default: 4200)', '4200') + .option('--no-open', 'Do not auto-open browser') + .action(async (opts) => { + const cwd = process.cwd(); + await runWeb({ + cwd, + configFile: opts.config, + port: Number(opts.port), + open: opts.open, + }); + }); + program .command('doctor') .description('Check if your environment is ready to build a Sparkling app') - .option('--platform ', 'Platform to check: android|ios|all', 'all') + .option('--platform ', 'Platform to check: android|ios|web|all', 'all') .action(async (opts) => { const raw = String(opts.platform ?? 'all').toLowerCase(); - const allowed = ['android', 'ios', 'all']; - const platform = (allowed.includes(raw) ? raw : 'all') as 'android' | 'ios' | 'all'; - if (!allowed.includes(raw)) { + const platform = (DOCTOR_PLATFORMS.includes(raw) ? raw : 'all') as 'android' | 'ios' | 'web' | 'all'; + if (!DOCTOR_PLATFORMS.includes(raw)) { console.warn(ui.warn(`Unknown platform "${opts.platform}", defaulting to 'all'.`)); } await doctor({ platform }); diff --git a/packages/sparkling-app-cli/src/types.ts b/packages/sparkling-app-cli/src/types.ts index 9ed6fd1b..1e1c97ac 100644 --- a/packages/sparkling-app-cli/src/types.ts +++ b/packages/sparkling-app-cli/src/types.ts @@ -12,6 +12,9 @@ export interface PlatformConfig { bundleIdentifier?: string; simulator?: string; }; + web?: { + port?: number; + }; } export type LynxConfig = unknown; @@ -55,6 +58,7 @@ export interface AppConfig { paths?: { androidAssets?: string; iosAssets?: string; + webAssets?: string; }; appIcon?: string; router?: RouterConfig; @@ -68,6 +72,7 @@ export interface MethodModuleConfig { root: string; /** When true, the module is a devtool module: linked with debugImplementation on Android and excluded from release on iOS. */ devtool?: boolean; + platforms?: string[]; android?: { packageName?: string; className?: string; @@ -81,4 +86,8 @@ export interface MethodModuleConfig { className?: string; podspecPath?: string; }; + web?: { + entryPoint?: string; + subpath?: string; + }; } diff --git a/packages/sparkling-method/package.json b/packages/sparkling-method/package.json index ac8ad67e..3ef70b37 100644 --- a/packages/sparkling-method/package.json +++ b/packages/sparkling-method/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web-registry": { + "types": "./dist/web-registry.d.ts", + "default": "./dist/web-registry.js" + } + }, + "typesVersions": { + "*": { + "web-registry": ["dist/web-registry.d.ts"] + } + }, "files": [ "dist", "ios", diff --git a/packages/sparkling-method/src/index.ts b/packages/sparkling-method/src/index.ts index 8ed9498d..379fbddc 100644 --- a/packages/sparkling-method/src/index.ts +++ b/packages/sparkling-method/src/index.ts @@ -4,7 +4,8 @@ /// -import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback } from './types'; +import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback, WebMethodHandler } from './types'; +import { getWebMethodHandler } from './web-registry'; export type { PipeResponse, @@ -12,6 +13,7 @@ export type { PipeCallOptions, MethodMap, EventCallback, + WebMethodHandler, }; /** @@ -91,6 +93,28 @@ const LynxPipe = { return; } + // Web handler dispatch — if a handler is registered, use it. + // On native the registry is empty (no /web imports), so this is a no-op. + // On web, @lynx-js/web-core provides a NativeModules stub without spkPipe, + // so we must dispatch here before the NativeModules checks below. + const webHandler = getWebMethodHandler(method); + if (webHandler) { + try { + webHandler( + { + containerID: getContainerID(), + protocolVersion: '1.0.0', + data: params ?? null, + }, + callback + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + callback(createErrorResponse(-5, `Web pipe call failed: ${errorMsg}`)); + } + return; + } + // Check if NativeModules is available if (typeof NativeModules === 'undefined') { callback(createErrorResponse(-2, 'NativeModules is not available. Ensure you are running in a lynx environment.')); diff --git a/packages/sparkling-method/src/types.ts b/packages/sparkling-method/src/types.ts index 08d3fd63..c7817da2 100644 --- a/packages/sparkling-method/src/types.ts +++ b/packages/sparkling-method/src/types.ts @@ -42,3 +42,13 @@ export type MethodMap = string | { * Pipe event callback */ export type EventCallback = (event: unknown) => void; + +/** + * Handler function for a web method implementation. + * Receives the same envelope that native modules receive and must + * invoke the callback with a `{ code, msg, data? }` response. + */ +export type WebMethodHandler = ( + params: { containerID: string; protocolVersion: string; data: unknown }, + callback: (response: { code: number; msg: string; data?: unknown }) => void, +) => void; diff --git a/packages/sparkling-method/src/web-registry.ts b/packages/sparkling-method/src/web-registry.ts new file mode 100644 index 00000000..1c15013c --- /dev/null +++ b/packages/sparkling-method/src/web-registry.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { WebMethodHandler } from './types'; + +export type { WebMethodHandler }; + +const registry = new Map(); + +/** + * Register a web implementation for a method. + * Called at import time by method package `/web` subpath exports. + */ +export function registerWebMethod(methodName: string, handler: WebMethodHandler): void { + if (!methodName || typeof methodName !== 'string') { + throw new Error('methodName must be a non-empty string'); + } + if (typeof handler !== 'function') { + throw new Error('handler must be a function'); + } + registry.set(methodName, handler); +} + +/** + * Look up a registered web handler by method name. + */ +export function getWebMethodHandler(methodName: string): WebMethodHandler | undefined { + return registry.get(methodName); +} + +/** + * Check whether a web handler is registered for the given method name. + */ +export function hasWebMethod(methodName: string): boolean { + return registry.has(methodName); +} + +/** + * Detect whether the current environment is a web browser + * (as opposed to the Lynx native runtime). + * + * Note: We check for `document` rather than the absence of `NativeModules` + * because `@lynx-js/web-core` defines a `NativeModules` stub in the browser. + * Native Lynx does not provide a `document` global. + */ +export function isWebEnvironment(): boolean { + return typeof document !== 'undefined'; +} diff --git a/packages/sparkling-web-shell/.gitignore b/packages/sparkling-web-shell/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/sparkling-web-shell/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/sparkling-web-shell/package.json b/packages/sparkling-web-shell/package.json new file mode 100644 index 00000000..a555b2b2 --- /dev/null +++ b/packages/sparkling-web-shell/package.json @@ -0,0 +1,38 @@ +{ + "name": "sparkling-web-shell", + "version": "2.1.0-rc.12", + "type": "module", + "description": "Minimal web host for rendering Lynx web bundles in a browser", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-web-shell" + }, + "scripts": { + "dev": "rsbuild dev", + "build": "rsbuild build", + "preview": "rsbuild preview" + }, + "files": [ + "src", + "rsbuild.config.ts", + "tsconfig.json" + ], + "dependencies": { + "@lynx-js/web-core": "0.19.8", + "@lynx-js/web-elements": "0.11.3", + "@rsbuild/core": "1.7.2", + "sparkling-method": "workspace:*", + "sparkling-navigation": "workspace:*", + "sparkling-storage": "workspace:*", + "sparkling-media": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "engines": { + "node": "^22 || ^24" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-web-shell/rsbuild.config.ts b/packages/sparkling-web-shell/rsbuild.config.ts new file mode 100644 index 00000000..e6a66c69 --- /dev/null +++ b/packages/sparkling-web-shell/rsbuild.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from '@rsbuild/core'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Default bundle directory: the playground's web build output +// Override via LYNX_BUNDLE_DIR env variable for CLI integration +const bundleDir = process.env.LYNX_BUNDLE_DIR + || path.join(__dirname, '../playground/dist/web'); + +// run:web passes PORT / BROWSER env variables (rsbuild does not read +// them on its own). +const port = Number.parseInt(process.env.PORT ?? '', 10); +const openBrowser = process.env.BROWSER !== 'none'; + +export default defineConfig({ + source: { + entry: { + index: './src/index.ts', + }, + }, + html: { + title: 'Sparkling Web Preview', + template: './src/index.html', + }, + server: { + port: Number.isFinite(port) ? port : 4200, + open: openBrowser, + publicDir: [ + { + name: path.resolve(bundleDir), + }, + ], + }, + output: { + assetPrefix: '/', + }, +}); diff --git a/packages/sparkling-web-shell/src/env.d.ts b/packages/sparkling-web-shell/src/env.d.ts new file mode 100644 index 00000000..d20f8a67 --- /dev/null +++ b/packages/sparkling-web-shell/src/env.d.ts @@ -0,0 +1,16 @@ +declare global { + interface LynxViewElement extends HTMLElement { + /** Main-thread callback for NativeModules RPC calls from the Worker */ + onNativeModulesCall?: ( + name: string, + data: unknown, + moduleName: string, + ) => Promise | unknown; + } + + interface HTMLElementTagNameMap { + 'lynx-view': LynxViewElement; + } +} + +export {}; diff --git a/packages/sparkling-web-shell/src/index.html b/packages/sparkling-web-shell/src/index.html new file mode 100644 index 00000000..e3acb93c --- /dev/null +++ b/packages/sparkling-web-shell/src/index.html @@ -0,0 +1,15 @@ + + + + + + Sparkling Web Preview + + + +
+ + diff --git a/packages/sparkling-web-shell/src/index.ts b/packages/sparkling-web-shell/src/index.ts new file mode 100644 index 00000000..494e3888 --- /dev/null +++ b/packages/sparkling-web-shell/src/index.ts @@ -0,0 +1,114 @@ +// Import Lynx web runtime and elements +import '@lynx-js/web-core'; +import '@lynx-js/web-core/index.css'; +import '@lynx-js/web-elements/all'; +import '@lynx-js/web-elements/index.css'; + +// Import web method handlers (self-registering). +// These register handlers in the main-thread registry where browser APIs +// (localStorage, window.history, document.createElement) are available. +import 'sparkling-navigation/web'; +import 'sparkling-storage/web'; +import 'sparkling-media/web'; + +import { getWebMethodHandler } from 'sparkling-method/web-registry'; + +/** + * Determine which bundle to load from the ?page= query parameter. + * Default: "main" -> /main.lynx.bundle + */ +function getBundleUrl(): string { + const params = new URLSearchParams(window.location.search); + const page = params.get('page') || 'main'; + return `/${page}.lynx.bundle`; +} + +/** + * Handle NativeModules RPC calls from the Worker thread. + * When the Lynx bundle calls NativeModules.spkPipe.call(method, data, callback), + * web-core bridges the call to this main-thread handler via onNativeModulesCall. + */ +function handleNativeModulesCall( + name: string, + data: unknown, + moduleName: string, +): Promise | unknown { + if (moduleName !== 'spkPipe') { + return undefined; + } + + const handler = getWebMethodHandler(name); + if (!handler) { + return { code: -3, msg: `Web handler not found for "${name}"` }; + } + + return new Promise((resolve) => { + handler( + data as { containerID: string; protocolVersion: string; data: unknown }, + (response) => resolve(response), + ); + }); +} + +/** + * Create a blob URL for a minimal ESM module that acts as the spkPipe + * NativeModule stub in the Worker. web-core dynamically imports this URL. + * The module exports a default object with a `call` method that the Worker's + * NativeModules will use. The actual call is bridged to the main thread + * via web-core's RPC mechanism and handled by onNativeModulesCall. + */ +const spkPipeModuleCode = ` +// Factory called by web-core's createNativeModules. +// Args: (nativeModules, callBridge) where callBridge sends RPC to main thread. +export default function(nativeModules, callBridge) { + return { + call(name, data, callback) { + callBridge(name, data).then(callback); + } + }; +}; +`; +const spkPipeBlob = new Blob([spkPipeModuleCode], { type: 'application/javascript' }); +const spkPipeModuleUrl = URL.createObjectURL(spkPipeBlob); + +/** + * Render the Lynx view into the page. + */ +function render(): void { + const bundleUrl = getBundleUrl(); + const container = document.getElementById('root'); + if (!container) { + console.error('[sparkling-web-shell] #root element not found'); + return; + } + + container.innerHTML = ''; + + const lynxView = document.createElement('lynx-view'); + lynxView.setAttribute('url', bundleUrl); + lynxView.style.width = '100vw'; + lynxView.style.height = '100vh'; + + // Register main-thread handler for spkPipe NativeModules calls. + // Must be set BEFORE adding to DOM (connectedCallback initializes the Worker). + lynxView.onNativeModulesCall = handleNativeModulesCall; + + // Register spkPipe in the native modules map so the Worker knows it exists. + const modulesMap = lynxView.nativeModulesMap as Record; + if (modulesMap) { + modulesMap['spkPipe'] = spkPipeModuleUrl; + } + + container.appendChild(lynxView); +} + +// Initial render +render(); + +// Listen for sparkling:navigate events dispatched by router.open web handler +window.addEventListener('sparkling:navigate', ((event: CustomEvent) => { + render(); +}) as EventListener); + +// Re-render when browser navigation changes the URL +window.addEventListener('popstate', render); diff --git a/packages/sparkling-web-shell/tsconfig.json b/packages/sparkling-web-shell/tsconfig.json new file mode 100644 index 00000000..9e63f58f --- /dev/null +++ b/packages/sparkling-web-shell/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/packages/web-navigation-shim/README.md b/packages/web-navigation-shim/README.md new file mode 100644 index 00000000..43ff2371 --- /dev/null +++ b/packages/web-navigation-shim/README.md @@ -0,0 +1,122 @@ +# web-navigation-shim + +A framework-agnostic implementation of the Web's navigation/history API +surface (`history`, `location`, `popstate`, `window.open`, …) on top of a +tiny pluggable **`NavigationHost`** contract. + +It exists to let web routing frameworks (vue-router/Nuxt, and in principle +react-router or anything speaking the History API) run inside JS contexts +that are **not** browser documents — most importantly Lynx pages managed by +Sparkling's native navigation — while cross-page navigation is delegated to +the host platform's real page stack. + +## The layering + +``` +┌─────────────────────────────────────────────────────────┐ +│ Router frameworks: vue-router / Nuxt / react-router / … │ (unchanged) +├─────────────────────────────────────────────────────────┤ +│ web-navigation-shim │ +│ history · location · popstate · window.open · document │ (this package) +├───────────────────────────┬─────────────────────────────┤ +│ NavigationHost contract │ initialUrl · open() · close()│ +├───────────────────────────┴─────────────────────────────┤ +│ Hosts: sparkling-navigation (router.open/close+scheme) │ +│ memory stack (tests/SSR) · any native shell │ +└─────────────────────────────────────────────────────────┘ +``` + +The shim never references Sparkling or Lynx. Any platform that can say +"this document was opened at URL X", "open URL Y as a new page", and +"close this page" can host any router framework that speaks the History +API. The Sparkling host implementation lives with the navigation method +(`sparkling-navigation`), not here — keeping this layer reusable outside +Sparkling. + +## The MPA model (why this is not a memory router) + +On Lynx + Sparkling, each page is its own LynxView with an **isolated JS +heap**. A vue-router "memory history" SPA can never navigate across pages, +because there is no shared memory to route in. The shim instead models the +real browser split: + +- **Same-document navigations** (`history.pushState/replaceState`, hash + changes) mutate a synthetic per-context entry stack — exactly like a + browser document's session history. Synchronous, no events, per spec. +- **Cross-document navigations** (`location.assign/replace/href=`, + `window.open`) are handed to `NavigationHost.open()` — on Sparkling that + becomes `router.open` with a sparkling scheme URL and a **new native + page** (new JS heap). +- **`history.back()`/`go(-n)` past the bottom of the local stack** becomes + `NavigationHost.close()`/`go()` — a **native pop** revealing the previous + page with all of its local state intact. + +Which URLs count as "same document" is a host policy +(`NavigationHost.isSameDocument`): the default mirrors the web (hash-only), +and a Sparkling host can widen it using its build-time route manifest +("same Lynx bundle ⇒ same document"). + +## What exactly is implemented + +Semantics were derived from an audit of everything Nuxt 4 + vue-router 5 +require from the browser for client-side routing (see +`docs/en/guide/nuxt-web-api-dependencies.md`): + +- `history.pushState/replaceState` — synchronous, event-free, forward-stack + truncation, absolute/path-only URL resolution, state round-trip + (vue-router's two-write push protocol relies on all of these). +- `history.go/back/forward` — async, **exactly one `popstate` per call**, + `location`/`state` updated before listeners run, out-of-range top = no-op + (guard rollback via `go(-delta)` depends on this), bottom-crossing = + native traversal. +- `history.length`, `history.state`, `history.scrollRestoration`. +- `location` — all WHATWG component getters/setters, `assign`, `replace`, + `reload`, stringifier. +- `popstate`/`pagehide`(window) and `visibilitychange`(document) listeners; + `notifyPageHide()` lets hosts trigger the scroll-persistence path. +- `document` boot stub (`typeof document` gate, `querySelector('base')`, + `visibilityState`), `navigator` stub, `requestAnimationFrame` fallback, + `scrollX/scrollY/scrollTo` stubs. +- `install(globalThis)` defines only missing globals (never clobbers a real + browser environment unless `force`). + +Requires a global WHATWG `URL` constructor in the JS context. + +## Usage + +```ts +import { createNavigationShim, type NavigationHost } from 'web-navigation-shim'; + +const host: NavigationHost = { + initialUrl: 'https://app.local/users/42', + open: (url, opts) => nativeOpen(url, opts), // e.g. sparkling router.open + close: () => nativeClose(), // e.g. sparkling router.close +}; + +const shim = createNavigationShim(host); +shim.install(globalThis); // before importing vue-router / booting Nuxt + +// vue-router's createWebHistory() now works in this context: +const router = createRouter({ history: createWebHistory(), routes }); +``` + +For tests (or SSR-ish hosts) there is an in-memory reference host that +models a native page stack of isolated documents: + +```ts +import { MemoryDocumentStack } from 'web-navigation-shim/memory'; + +const stack = new MemoryDocumentStack('https://app.local/'); +stack.top.shim.location.assign('/detail'); // "native push": new document +stack.top.shim.history.back(); // "native pop": reveals '/' +``` + +## Verification + +`__tests__/` runs in plain Node (no jsdom) to prove self-sufficiency: + +- WHATWG semantics unit tests (history/location/events). +- MPA stack semantics against `MemoryDocumentStack`. +- **Integration: real vue-router 5 `createWebHistory` running on the shim** + — boot, push, state bookkeeping, back/forward, guard rollback, and the + manifest-guard MPA handoff pattern used by the Sparkling router glue. diff --git a/packages/web-navigation-shim/__tests__/history.spec.ts b/packages/web-navigation-shim/__tests__/history.spec.ts new file mode 100644 index 00000000..b8092de3 --- /dev/null +++ b/packages/web-navigation-shim/__tests__/history.spec.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, expect, it, vi } from 'vitest'; +import { createNavigationShim } from '../src/shim'; +import type { NavigationHost } from '../src/types'; + +const BASE = 'https://app.local/'; + +function stubHost(overrides: Partial = {}): NavigationHost { + return { + initialUrl: BASE, + open: vi.fn(), + close: vi.fn(), + ...overrides, + }; +} + +const microtask = () => new Promise((resolve) => queueMicrotask(resolve)); + +describe('history.pushState / replaceState', () => { + it('updates state and location synchronously without firing popstate', () => { + const shim = createNavigationShim(stubHost()); + const popstate = vi.fn(); + shim.window.addEventListener('popstate', popstate); + + const state = { position: 1, current: '/a' }; + shim.history.pushState(state, '', '/a'); + + expect(shim.history.state).toEqual(state); + expect(shim.location.pathname).toBe('/a'); + expect(shim.history.length).toBe(2); + expect(popstate).not.toHaveBeenCalled(); + }); + + it('supports the vue-router two-write protocol (replace then push)', () => { + const shim = createNavigationShim(stubHost()); + + // vue-router: replaceState(currentEntry + {forward, scroll}) then pushState(new) + shim.history.replaceState({ current: '/', forward: '/a', scroll: { left: 0, top: 0 } }, '', BASE); + expect(shim.history.state).toMatchObject({ forward: '/a' }); + + shim.history.pushState({ current: '/a', position: 1 }, '', '/a'); + expect(shim.history.state).toEqual({ current: '/a', position: 1 }); + expect(shim.location.href).toBe(`${BASE}a`); + }); + + it('accepts absolute same-origin URLs and path-only URLs', () => { + const shim = createNavigationShim(stubHost()); + shim.history.pushState({}, '', `${BASE}abs`); + expect(shim.location.pathname).toBe('/abs'); + shim.history.pushState({}, '', '/rel?q=1#h'); + expect(shim.location.pathname).toBe('/rel'); + expect(shim.location.search).toBe('?q=1'); + expect(shim.location.hash).toBe('#h'); + }); + + it('null/omitted url keeps the current URL', () => { + const shim = createNavigationShim(stubHost()); + shim.history.pushState({ a: 1 }, ''); + expect(shim.location.href).toBe(BASE); + expect(shim.history.length).toBe(2); + }); + + it('pushState truncates forward entries', async () => { + const shim = createNavigationShim(stubHost()); + shim.history.pushState({}, '', '/a'); + shim.history.pushState({}, '', '/b'); + shim.history.go(-1); + await microtask(); + expect(shim.location.pathname).toBe('/a'); + + shim.history.pushState({}, '', '/c'); + expect(shim.history.length).toBe(3); // '/', '/a', '/c' + // forward is gone: + shim.history.go(1); + await microtask(); + expect(shim.location.pathname).toBe('/c'); + }); +}); + +describe('history.go', () => { + it('fires exactly one popstate per go() with destination state, after location updates', async () => { + const shim = createNavigationShim(stubHost()); + shim.history.pushState({ page: 'a' }, '', '/a'); + shim.history.pushState({ page: 'b' }, '', '/b'); + + const seen: Array<{ state: unknown; pathname: string }> = []; + shim.window.addEventListener('popstate', (event) => { + const e = event as { state: unknown }; + seen.push({ state: e.state, pathname: shim.location.pathname }); + }); + + shim.history.go(-2); + await microtask(); + expect(seen).toEqual([{ state: null, pathname: '/' }]); + + shim.history.go(2); + await microtask(); + expect(seen).toHaveLength(2); + expect(seen[1]).toEqual({ state: { page: 'b' }, pathname: '/b' }); + }); + + it('go() past the top of the stack is a no-op', async () => { + const shim = createNavigationShim(stubHost()); + const popstate = vi.fn(); + shim.window.addEventListener('popstate', popstate); + shim.history.go(1); + await microtask(); + expect(popstate).not.toHaveBeenCalled(); + expect(shim.location.href).toBe(BASE); + }); + + it('go(0) is a no-op', async () => { + const shim = createNavigationShim(stubHost()); + const popstate = vi.fn(); + shim.window.addEventListener('popstate', popstate); + shim.history.go(0); + await microtask(); + expect(popstate).not.toHaveBeenCalled(); + }); + + it('back() at the bottom of the local stack delegates to the host (native pop)', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.history.back(); + expect(host.close).toHaveBeenCalledTimes(1); + }); + + it('go(-n) crossing the local bottom passes the full remaining delta to host.go', () => { + const go = vi.fn(); + const host = stubHost({ go }); + const shim = createNavigationShim(host); + shim.history.pushState({}, '', '/a'); // local stack: '/', '/a' (index 1) + shim.history.go(-3); + expect(go).toHaveBeenCalledWith(-2); + }); + + it('state is read through history.state after traversal (vue-router global read)', async () => { + const shim = createNavigationShim(stubHost()); + shim.history.pushState({ position: 1 }, '', '/a'); + shim.history.go(-1); + await microtask(); + expect(shim.history.state).toBeNull(); + }); +}); + +describe('scrollRestoration', () => { + it('exists, defaults to auto, and accepts manual (vue-router feature test)', () => { + const shim = createNavigationShim(stubHost()); + expect('scrollRestoration' in shim.history).toBe(true); + expect(shim.history.scrollRestoration).toBe('auto'); + shim.history.scrollRestoration = 'manual'; + expect(shim.history.scrollRestoration).toBe('manual'); + }); +}); diff --git a/packages/web-navigation-shim/__tests__/location.spec.ts b/packages/web-navigation-shim/__tests__/location.spec.ts new file mode 100644 index 00000000..b131a7c4 --- /dev/null +++ b/packages/web-navigation-shim/__tests__/location.spec.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, expect, it, vi } from 'vitest'; +import { createNavigationShim } from '../src/shim'; +import type { NavigationHost } from '../src/types'; + +const BASE = 'https://app.local/users/42?tab=posts'; + +function stubHost(overrides: Partial = {}): NavigationHost { + return { + initialUrl: BASE, + open: vi.fn(), + close: vi.fn(), + ...overrides, + }; +} + +const microtask = () => new Promise((resolve) => queueMicrotask(resolve)); + +describe('location component getters', () => { + it('exposes WHATWG components of the current URL', () => { + const shim = createNavigationShim(stubHost()); + expect(shim.location.href).toBe(BASE); + expect(shim.location.origin).toBe('https://app.local'); + expect(shim.location.protocol).toBe('https:'); + expect(shim.location.host).toBe('app.local'); + expect(shim.location.pathname).toBe('/users/42'); + expect(shim.location.search).toBe('?tab=posts'); + expect(shim.location.hash).toBe(''); + expect(String(shim.location)).toBe(BASE); + }); +}); + +describe('cross-document navigation → host.open', () => { + it('assign() to a different path leaves the document', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.assign('/settings'); + expect(host.open).toHaveBeenCalledWith('https://app.local/settings', {}); + }); + + it('href setter behaves like assign()', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.href = 'https://app.local/other'; + expect(host.open).toHaveBeenCalledWith('https://app.local/other', {}); + }); + + it('replace() forwards replace intent to the host', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.replace('/login'); + expect(host.open).toHaveBeenCalledWith('https://app.local/login', { replace: true }); + }); + + it('window.open() opens through the host with newWindow', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.window.open('https://example.com/docs'); + expect(host.open).toHaveBeenCalledWith('https://example.com/docs', { newWindow: true }); + }); + + it('reload() defaults to replace-open of the current URL', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.reload(); + expect(host.open).toHaveBeenCalledWith(BASE, { replace: true }); + }); + + it('reload() prefers host.reload when provided', () => { + const reload = vi.fn(); + const host = stubHost({ reload }); + const shim = createNavigationShim(host); + shim.location.reload(); + expect(reload).toHaveBeenCalled(); + expect(host.open).not.toHaveBeenCalled(); + }); +}); + +describe('same-document (hash) navigation', () => { + it('hash-only assign stays in-document: entry + popstate, no host.open', async () => { + const host = stubHost(); + const shim = createNavigationShim(host); + const popstate = vi.fn(); + shim.window.addEventListener('popstate', popstate); + + shim.location.assign(`${BASE}#section`); + await microtask(); + + expect(host.open).not.toHaveBeenCalled(); + expect(shim.location.hash).toBe('#section'); + expect(shim.history.length).toBe(2); + expect(popstate).toHaveBeenCalledTimes(1); + // hash navigations carry null state (vue-router treats these as external) + expect((popstate.mock.calls[0][0] as { state: unknown }).state).toBeNull(); + }); + + it('hash setter with identical value does nothing', async () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.assign(`${BASE}#a`); + await microtask(); + const popstate = vi.fn(); + shim.window.addEventListener('popstate', popstate); + shim.location.hash = '#a'; + await microtask(); + expect(popstate).not.toHaveBeenCalled(); + expect(shim.history.length).toBe(2); + }); + + it('host.isSameDocument policy can widen same-document identity', async () => { + // A Sparkling host backed by a route manifest may declare that two + // paths served by the same Lynx bundle share a document. + const host = stubHost({ + isSameDocument: (from, to) => from.pathname.split('/')[1] === to.pathname.split('/')[1], + }); + const shim = createNavigationShim(host); + shim.location.assign('/users/43'); + await microtask(); + expect(host.open).not.toHaveBeenCalled(); + expect(shim.location.pathname).toBe('/users/43'); + + shim.location.assign('/settings'); + expect(host.open).toHaveBeenCalledWith('https://app.local/settings', {}); + }); +}); + +describe('pathname/search/hash setters', () => { + it('pathname setter triggers cross-document navigation, preserving search (spec)', () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.pathname = '/elsewhere'; + expect(host.open).toHaveBeenCalledWith('https://app.local/elsewhere?tab=posts', {}); + }); + + it('hash setter normalizes a missing # prefix', async () => { + const host = stubHost(); + const shim = createNavigationShim(host); + shim.location.hash = 'anchor'; + await microtask(); + expect(shim.location.hash).toBe('#anchor'); + expect(host.open).not.toHaveBeenCalled(); + }); +}); + +describe('install()', () => { + it('defines missing globals without clobbering existing ones', () => { + const shim = createNavigationShim(stubHost()); + const target: Record = { document: { existing: true } }; + shim.install(target); + expect(target.history).toBe(shim.history); + expect(target.location).toBe(shim.location); + expect(target.window).toBe(shim.window); + expect(target.document).toEqual({ existing: true }); + expect(typeof target.requestAnimationFrame).toBe('function'); + expect(typeof target.addEventListener).toBe('function'); + expect(target.navigator).toBeDefined(); + expect(target.scrollX).toBe(0); + }); +}); + +describe('notifyPageHide()', () => { + it('dispatches visibilitychange (document) and pagehide (window) with hidden state', () => { + const shim = createNavigationShim(stubHost()); + const seen: string[] = []; + shim.document.addEventListener('visibilitychange', () => { + seen.push(`visibility:${shim.document.visibilityState}`); + }); + shim.window.addEventListener('pagehide', () => seen.push('pagehide')); + shim.notifyPageHide(); + expect(seen).toEqual(['visibility:hidden', 'pagehide']); + }); +}); diff --git a/packages/web-navigation-shim/__tests__/memory-host.spec.ts b/packages/web-navigation-shim/__tests__/memory-host.spec.ts new file mode 100644 index 00000000..fe4484d9 --- /dev/null +++ b/packages/web-navigation-shim/__tests__/memory-host.spec.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, expect, it } from 'vitest'; +import { MemoryDocumentStack } from '../src/hosts/memory'; + +const microtask = () => new Promise((resolve) => queueMicrotask(resolve)); + +describe('MemoryDocumentStack (MPA semantics reference)', () => { + it('cross-document assign opens a new document with its own shim (isolated heap model)', () => { + const stack = new MemoryDocumentStack('https://app.local/'); + const first = stack.top; + + first.shim.location.assign('/detail'); + + expect(stack.depth).toBe(2); + expect(stack.top.url).toBe('https://app.local/detail'); + // The two documents own independent history stacks: + expect(stack.top.shim).not.toBe(first.shim); + expect(stack.top.shim.history.length).toBe(1); + expect(first.shim.history.length).toBe(1); + }); + + it('history.back() on a fresh document pops the native stack and reveals the previous document intact', async () => { + const stack = new MemoryDocumentStack('https://app.local/'); + const first = stack.top; + first.shim.history.pushState({ scroll: 120 }, '', '/?expanded=1'); + + first.shim.location.assign('/detail'); + const second = stack.top; + expect(second.url).toBe('https://app.local/detail'); + + // Native back from the detail page: + second.shim.history.back(); + expect(stack.depth).toBe(1); + // Previous document's local state survived (it never tore down): + expect(stack.top).toBe(first); + expect(first.shim.location.search).toBe('?expanded=1'); + expect(first.shim.history.state).toEqual({ scroll: 120 }); + await microtask(); + }); + + it('replace() swaps the top document instead of pushing', () => { + const stack = new MemoryDocumentStack('https://app.local/'); + stack.top.shim.location.assign('/a'); + expect(stack.depth).toBe(2); + stack.top.shim.location.replace('/b'); + expect(stack.depth).toBe(2); + expect(stack.top.url).toBe('https://app.local/b'); + }); + + it('go(-n) crossing local stack bottom traverses multiple native documents', () => { + const stack = new MemoryDocumentStack('https://app.local/'); + stack.top.shim.location.assign('/a'); + stack.top.shim.location.assign('/b'); + expect(stack.depth).toBe(3); + + // From /b, go(-2) has no local entries to consume → native go(-2) + stack.top.shim.history.go(-2); + expect(stack.depth).toBe(1); + expect(stack.top.url).toBe('https://app.local/'); + }); + + it('mixed local + native traversal: local entries are consumed locally first', async () => { + const stack = new MemoryDocumentStack('https://app.local/'); + stack.top.shim.location.assign('/list'); + const list = stack.top; + list.shim.history.pushState({}, '', '/list?page=2'); + + // go(-1) stays within the document… + list.shim.history.go(-1); + await microtask(); + expect(stack.depth).toBe(2); + expect(list.shim.location.pathname + list.shim.location.search).toBe('/list'); + + // …and one more go(-1) crosses to the native stack. + list.shim.history.go(-1); + expect(stack.depth).toBe(1); + }); + + it('closing the last document is refused (root container stays)', () => { + const stack = new MemoryDocumentStack('https://app.local/'); + stack.top.shim.history.back(); + expect(stack.depth).toBe(1); + }); + + it('pagehide fires on the closed document', () => { + const stack = new MemoryDocumentStack('https://app.local/'); + stack.top.shim.location.assign('/bye'); + const closing = stack.top; + let hidden = false; + closing.shim.window.addEventListener('pagehide', () => { + hidden = true; + }); + closing.shim.history.back(); + expect(hidden).toBe(true); + expect(closing.shim.document.visibilityState).toBe('hidden'); + }); +}); diff --git a/packages/web-navigation-shim/__tests__/vue-router.spec.ts b/packages/web-navigation-shim/__tests__/vue-router.spec.ts new file mode 100644 index 00000000..fabadfce --- /dev/null +++ b/packages/web-navigation-shim/__tests__/vue-router.spec.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Integration test: real vue-router `createWebHistory` running entirely on + * the shim, in plain Node (no jsdom). This is the exact code path Nuxt + * uses on the client, so passing here means the shim satisfies + * vue-router's browser-history contract: + * - install-time globals gate (`typeof document`) + * - two-write push protocol (replaceState + pushState) + * - history.state round-trip and position accounting + * - popstate-driven back/forward with guard rollback (go(-delta)) + */ + +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { createNavigationShim } from '../src/shim'; +import type { NavigationShim } from '../src/shim'; +import type { NavigationHost } from '../src/types'; + +type VueRouterModule = typeof import('vue-router'); + +const BASE = 'https://app.local/'; + +let vueRouter: VueRouterModule; +let shim: NavigationShim; +let hostOpen: ReturnType; +let hostClose: ReturnType; + +const Empty = { render: () => null }; + +const nextNavigation = (router: import('vue-router').Router) => + new Promise((resolve, reject) => { + const stopOk = router.afterEach(() => { + stopOk(); + stopErr(); + resolve(); + }); + const stopErr = router.onError((error) => { + stopOk(); + stopErr(); + reject(error); + }); + }); + +beforeAll(async () => { + hostOpen = vi.fn(); + hostClose = vi.fn(); + const host: NavigationHost = { + initialUrl: BASE, + open: hostOpen, + close: hostClose, + }; + shim = createNavigationShim(host); + // vue-router captures `isBrowser` (typeof document) at import time, so the + // shim must be installed onto globalThis BEFORE the dynamic import below. + shim.install(globalThis as unknown as Record); + vueRouter = await import('vue-router'); +}); + +describe('vue-router createWebHistory over the shim', () => { + it('boots, pushes, and reads back history.state (two-write protocol)', async () => { + const router = vueRouter.createRouter({ + history: vueRouter.createWebHistory(), + routes: [ + { path: '/', component: Empty }, + { path: '/a', component: Empty }, + { path: '/users/:id', component: Empty }, + { path: '/native/:rest(.*)', component: Empty }, + ], + }); + + // Simulate what router.install() does in a browser: initial navigation. + await router.push(shim.location.pathname + shim.location.search + shim.location.hash); + await router.isReady(); + expect(router.currentRoute.value.path).toBe('/'); + + await router.push('/a'); + expect(router.currentRoute.value.path).toBe('/a'); + expect(shim.location.pathname).toBe('/a'); + // vue-router state bookkeeping landed in our synthetic history: + const state = shim.history.state as Record; + expect(state.current).toBe('/a'); + expect(state.back).toBe('/'); + expect(typeof state.position).toBe('number'); + // The departed entry got the forward pointer via replaceState: + expect((shim.entries[shim.index - 1].state as Record).forward).toBe('/a'); + + await router.push({ path: '/users/42', query: { tab: 'posts' } }); + expect(shim.location.pathname).toBe('/users/42'); + expect(shim.location.search).toBe('?tab=posts'); + + // Back/forward traverse the synthetic stack through popstate: + const back = nextNavigation(router); + router.back(); + await back; + expect(router.currentRoute.value.path).toBe('/a'); + expect(shim.location.pathname).toBe('/a'); + + const forward = nextNavigation(router); + router.forward(); + await forward; + expect(router.currentRoute.value.fullPath).toBe('/users/42?tab=posts'); + }); + + it('guard rollback on back-navigation restores position via go(+delta)', async () => { + const router = vueRouter.createRouter({ + history: vueRouter.createWebHistory(), + routes: [ + { path: '/', component: Empty }, + { path: '/a', component: Empty }, + { path: '/users/:id', component: Empty }, + { path: '/blocked', component: Empty }, + ], + }); + await router.push('/'); + await router.isReady(); + await router.push('/blocked'); + await router.push('/a'); + + let block = true; + router.beforeEach((to) => { + if (block && to.path === '/blocked') return false; + return true; + }); + + // Hardware/UI back to /blocked gets cancelled by the guard; vue-router + // must roll the history back forward to /a without ending up in limbo. + router.back(); + await vi.waitFor(() => { + expect(shim.location.pathname).toBe('/a'); + }); + expect(router.currentRoute.value.path).toBe('/a'); + block = false; + }); + + it('router-level MPA handoff: manifest guard opens a new native document', async () => { + const router = vueRouter.createRouter({ + history: vueRouter.createWebHistory(), + routes: [ + { path: '/', component: Empty }, + { path: '/native/:rest(.*)', component: Empty }, + ], + }); + // The pattern the Sparkling router glue uses: routes living in another + // Lynx bundle are opened through the host instead of rendered locally. + router.beforeEach((to) => { + if (to.path.startsWith('/native/')) { + shim.location.assign(to.fullPath); + return false; + } + return true; + }); + await router.push('/'); + await router.isReady(); + + await router.push('/native/details?id=7').catch(() => { + // vue-router surfaces the aborted navigation; the handoff already happened. + }); + expect(hostOpen).toHaveBeenCalledWith('https://app.local/native/details?id=7', {}); + expect(router.currentRoute.value.path).toBe('/'); + }); + + it('back at the bottom of the local stack becomes a native close', async () => { + // Fresh shim modeling a freshly opened native page. The globally + // installed shim keeps serving vue-router's bare-global reads + // (history.state), which is exactly the per-context split we model. + const localClose = vi.fn(); + const localShim = createNavigationShim({ + initialUrl: 'https://app.local/detail', + open: vi.fn(), + close: localClose, + }); + localShim.history.back(); + expect(localClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web-navigation-shim/package.json b/packages/web-navigation-shim/package.json new file mode 100644 index 00000000..a1b32e4c --- /dev/null +++ b/packages/web-navigation-shim/package.json @@ -0,0 +1,56 @@ +{ + "name": "web-navigation-shim", + "version": "2.1.0-rc.12", + "description": "Framework-agnostic Web navigation/history API shim over a pluggable NavigationHost contract (Sparkling, Lynx, or any native multi-page host)", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/web-navigation-shim" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./memory": { + "types": "./dist/src/hosts/memory.d.ts", + "default": "./dist/src/hosts/memory.js" + } + }, + "typesVersions": { + "*": { + "memory": [ + "dist/src/hosts/memory.d.ts" + ] + } + }, + "files": [ + "src", + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vitest": "^4.0.18", + "vue-router": "^5.1.0", + "vue": "^3.5.13", + "@vue/runtime-core": "^3.5.13" + }, + "keywords": [ + "history", + "navigation", + "shim", + "lynx", + "sparkling", + "router" + ], + "license": "Apache-2.0" +} diff --git a/packages/web-navigation-shim/src/events.ts b/packages/web-navigation-shim/src/events.ts new file mode 100644 index 00000000..882ad108 --- /dev/null +++ b/packages/web-navigation-shim/src/events.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { ShimEventListener } from './types'; + +/** + * Minimal EventTarget replacement. Lynx JS contexts do not guarantee a + * global `EventTarget`, and the shim only needs add/remove/dispatch with + * string types — no capture, bubbling, or `once` options. + */ +export class SimpleEventTarget { + private listeners = new Map>(); + + addEventListener(type: string, listener: ShimEventListener | null | undefined): void { + if (typeof listener !== 'function') return; + let set = this.listeners.get(type); + if (!set) { + set = new Set(); + this.listeners.set(type, set); + } + set.add(listener); + } + + removeEventListener(type: string, listener: ShimEventListener | null | undefined): void { + if (typeof listener !== 'function') return; + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: { type: string }): boolean { + const set = this.listeners.get(event.type); + if (!set) return true; + // Copy: listeners may unregister themselves while we iterate. + for (const listener of [...set]) { + try { + listener(event); + } catch (error) { + // Mirror browser behavior: one broken listener must not stop the rest. + // eslint-disable-next-line no-console + console.error('[web-navigation-shim] listener error:', error); + } + } + return true; + } + + hasListeners(type: string): boolean { + return (this.listeners.get(type)?.size ?? 0) > 0; + } +} diff --git a/packages/web-navigation-shim/src/hosts/memory.ts b/packages/web-navigation-shim/src/hosts/memory.ts new file mode 100644 index 00000000..81ff9d6e --- /dev/null +++ b/packages/web-navigation-shim/src/hosts/memory.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { createNavigationShim } from '../shim'; +import type { NavigationShim } from '../shim'; +import type { HostOpenOptions, NavigationHost } from '../types'; + +/** + * A "document" living in a {@link MemoryDocumentStack}. Each document owns + * its own shim instance — modeling the real Sparkling/Lynx situation where + * every container page runs in an isolated JS heap and only the native + * stack connects them. + */ +export interface MemoryDocument { + url: string; + host: NavigationHost; + shim: NavigationShim; +} + +export interface MemoryDocumentStackOptions { + /** Same-document policy shared by all documents (see NavigationHost). */ + isSameDocument?(from: URL, to: URL): boolean; + /** Observe stack mutations (tests, logging). */ + onChange?(stack: MemoryDocumentStack): void; +} + +/** + * In-memory NavigationHost implementation: a stack of documents in one JS + * heap. Useful for unit tests and for SSR-ish environments, and the + * reference implementation of the {@link NavigationHost} contract's + * expected stack semantics. + */ +export class MemoryDocumentStack { + private documents: MemoryDocument[] = []; + private options: MemoryDocumentStackOptions; + + constructor(initialUrl: string, options: MemoryDocumentStackOptions = {}) { + this.options = options; + this.pushDocument(initialUrl); + } + + get depth(): number { + return this.documents.length; + } + + get top(): MemoryDocument { + return this.documents[this.documents.length - 1]; + } + + at(index: number): MemoryDocument | undefined { + return this.documents[index]; + } + + private pushDocument(url: string, replace = false): MemoryDocument { + const stack = this; + const host: NavigationHost = { + initialUrl: url, + open(nextUrl: string, options?: HostOpenOptions) { + stack.open(nextUrl, options); + }, + close() { + stack.close(); + }, + go(delta: number) { + stack.go(delta); + }, + isSameDocument: this.options.isSameDocument, + }; + const doc: MemoryDocument = { url, host, shim: createNavigationShim(host) }; + if (replace && this.documents.length > 0) { + this.documents[this.documents.length - 1] = doc; + } else { + this.documents.push(doc); + } + this.options.onChange?.(this); + return doc; + } + + /** Native push (or replace) of a new document. */ + open(url: string, options: HostOpenOptions = {}): MemoryDocument { + return this.pushDocument(url, options.replace === true); + } + + /** Native pop of the top document. The bottom document never closes. */ + close(): void { + if (this.documents.length > 1) { + const closed = this.documents.pop(); + closed?.shim.notifyPageHide(); + this.options.onChange?.(this); + } + } + + /** Native traversal by delta documents (negative = back). */ + go(delta: number): void { + if (delta >= 0) return; // no forward stack in a native container stack + const steps = Math.min(-delta, this.documents.length - 1); + for (let i = 0; i < steps; i += 1) { + this.close(); + } + } +} + +/** + * Convenience: create a single-document memory host (no stack semantics + * needed) — enough for unit-testing shims in isolation. + */ +export function createMemoryHost(initialUrl: string, options: MemoryDocumentStackOptions = {}): NavigationHost { + return new MemoryDocumentStack(initialUrl, options).top.host; +} diff --git a/packages/web-navigation-shim/src/index.ts b/packages/web-navigation-shim/src/index.ts new file mode 100644 index 00000000..25405c94 --- /dev/null +++ b/packages/web-navigation-shim/src/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { createNavigationShim } from './shim'; +export type { + NavigationShim, + ShimHistory, + ShimLocation, + ShimDocument, + ShimWindow, +} from './shim'; +export type { + NavigationHost, + HostOpenOptions, + ShimHistoryEntry, + ShimPopStateEvent, + ShimEventListener, +} from './types'; +export { SimpleEventTarget } from './events'; diff --git a/packages/web-navigation-shim/src/shim.ts b/packages/web-navigation-shim/src/shim.ts new file mode 100644 index 00000000..011143dd --- /dev/null +++ b/packages/web-navigation-shim/src/shim.ts @@ -0,0 +1,430 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { SimpleEventTarget } from './events'; +import type { + HostOpenOptions, + NavigationHost, + ShimHistoryEntry, + ShimPopStateEvent, +} from './types'; + +/** + * `History`-compatible surface produced by {@link createNavigationShim}. + * Semantics follow WHATWG HTML where they matter to routers (vue-router, + * react-router, Navigation-less frameworks): + * + * - `pushState`/`replaceState` are synchronous, never fire events, never + * leave the document (spec behavior). + * - `go()` is asynchronous and dispatches exactly one `popstate` per call, + * after `location` and `state` already reflect the destination. + * - Out-of-range `go()` beyond the top of the stack is a no-op (spec); + * crossing the bottom of the stack delegates to the host (native back). + */ +export interface ShimHistory { + readonly length: number; + readonly state: unknown; + scrollRestoration: 'auto' | 'manual'; + pushState(state: unknown, unused: string, url?: string | null): void; + replaceState(state: unknown, unused: string, url?: string | null): void; + go(delta?: number): void; + back(): void; + forward(): void; +} + +/** `Location`-compatible surface produced by {@link createNavigationShim}. */ +export interface ShimLocation { + href: string; + readonly origin: string; + readonly protocol: string; + readonly host: string; + readonly hostname: string; + readonly port: string; + pathname: string; + search: string; + hash: string; + assign(url: string): void; + replace(url: string): void; + reload(): void; + toString(): string; +} + +/** Minimal `document` stub for router boot gates (`typeof document`). */ +export interface ShimDocument { + visibilityState: 'visible' | 'hidden'; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; + dispatchEvent(event: { type: string }): boolean; + /** Always null — enough for `document.querySelector('base')` probes. */ + querySelector(selectors: string): null; + /** Always null — enough for scroll-target lookups to fail gracefully. */ + getElementById(id: string): null; +} + +/** The window-like object aggregating everything the shim provides. */ +export interface ShimWindow { + window: ShimWindow; + history: ShimHistory; + location: ShimLocation; + document: ShimDocument; + navigator: Record; + scrollX: number; + scrollY: number; + scrollTo(...args: unknown[]): void; + open(url?: string, target?: string, features?: string): null; + requestAnimationFrame(callback: (time: number) => void): ReturnType; + cancelAnimationFrame(handle: ReturnType): void; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; + dispatchEvent(event: { type: string }): boolean; +} + +export interface NavigationShim { + /** The aggregated window-like object. */ + window: ShimWindow; + history: ShimHistory; + location: ShimLocation; + document: ShimDocument; + /** + * Define the shim objects as globals on `target` (default `globalThis`) + * so code compiled against bare `window`/`history`/`location`/`document` + * (vue-router, Nuxt runtime, …) finds them. Existing globals are NOT + * overwritten unless `force` is set — never install with `force` inside + * a real browser page. + */ + install(target?: Record, options?: { force?: boolean }): void; + /** + * Notify the shim that the host is hiding/tearing down this document, so + * `pagehide`/`visibilitychange` listeners (scroll persistence in + * vue-router) run. Optional for hosts. + */ + notifyPageHide(): void; + /** Snapshot of the synthetic entry stack (diagnostics/tests). */ + readonly entries: readonly ShimHistoryEntry[]; + /** Index of the current entry within {@link entries}. */ + readonly index: number; +} + +function parseUrl(url: string, base?: string): URL { + if (typeof URL !== 'function') { + throw new TypeError( + '[web-navigation-shim] global URL constructor is required. ' + + 'Provide a WHATWG URL polyfill in this JS context before creating the shim.', + ); + } + return base === undefined ? new URL(url) : new URL(url, base); +} + +/** Default same-document policy: only hash-only changes stay in-document. */ +function defaultIsSameDocument(from: URL, to: URL): boolean { + return from.origin === to.origin + && from.pathname === to.pathname + && from.search === to.search; +} + +/** + * Create the Web navigation API surface (history/location/popstate/window + * bits) on top of a {@link NavigationHost}. + */ +export function createNavigationShim(host: NavigationHost): NavigationShim { + const initial = parseUrl(host.initialUrl); + const entries: ShimHistoryEntry[] = [{ url: initial.href, state: null }]; + let index = 0; + let scrollRestoration: 'auto' | 'manual' = 'auto'; + + const windowEvents = new SimpleEventTarget(); + const documentEvents = new SimpleEventTarget(); + + const current = (): ShimHistoryEntry => entries[index]; + const currentUrl = (): URL => parseUrl(current().url); + + const resolve = (url: string | null | undefined): URL => + parseUrl(url == null || url === '' ? current().url : String(url), current().url); + + const isSameDocument = (from: URL, to: URL): boolean => + host.isSameDocument ? host.isSameDocument(from, to) : defaultIsSameDocument(from, to); + + // --------------------------------------------------------------------- + // popstate + // --------------------------------------------------------------------- + + function createPopStateEvent(state: unknown): ShimPopStateEvent { + const event: ShimPopStateEvent = { + type: 'popstate', + state, + target: undefined as unknown, // assigned below once `windowLike` exists + hasUAVisualTransition: false, + defaultPrevented: false, + preventDefault() { + event.defaultPrevented = true; + }, + }; + event.target = windowLike; + return event; + } + + function dispatchPopState(state: unknown): void { + windowEvents.dispatchEvent(createPopStateEvent(state)); + } + + // --------------------------------------------------------------------- + // history + // --------------------------------------------------------------------- + + function pushState(state: unknown, _unused: string, url?: string | null): void { + const to = resolve(url); + // Spec: pushState never navigates the document, regardless of target. + entries.splice(index + 1); // drop forward entries + entries.push({ url: to.href, state: state ?? null }); + index = entries.length - 1; + } + + function replaceState(state: unknown, _unused: string, url?: string | null): void { + const to = resolve(url); + entries[index] = { url: to.href, state: state ?? null }; + } + + function go(delta = 0): void { + if (delta === 0) { + // Browsers reload on go(0); routers never rely on this. Treat as no-op. + return; + } + const target = index + delta; + if (target >= entries.length) { + // Beyond the top of the joint session history: spec says do nothing. + return; + } + if (target < 0) { + // Crossing the bottom of this document's stack: native traversal. + // The steps consumed locally are index → 0; the remainder goes to the host. + const hostDelta = target; // negative + if (host.go) { + host.go(hostDelta); + } else { + host.close(); + } + return; + } + // Same-document traversal: async, exactly one popstate per go() call, + // with location/state updated before listeners run (spec order). + queueMicrotask(() => { + index = target; + dispatchPopState(current().state); + }); + } + + const history: ShimHistory = { + get length() { + return entries.length; + }, + get state() { + return current().state; + }, + get scrollRestoration() { + return scrollRestoration; + }, + set scrollRestoration(value: 'auto' | 'manual') { + if (value === 'auto' || value === 'manual') scrollRestoration = value; + }, + pushState, + replaceState, + go, + back() { + go(-1); + }, + forward() { + go(1); + }, + }; + + // --------------------------------------------------------------------- + // location + // --------------------------------------------------------------------- + + function navigate(url: string, options: HostOpenOptions & { replace?: boolean }): void { + const from = currentUrl(); + const to = resolve(url); + if (isSameDocument(from, to)) { + // Same-document (fragment) navigation: history entry + popstate, + // mirroring browser behavior for hash navigations. + if (options.replace) { + entries[index] = { url: to.href, state: null }; + } else { + entries.splice(index + 1); + entries.push({ url: to.href, state: null }); + index = entries.length - 1; + } + queueMicrotask(() => dispatchPopState(current().state)); + return; + } + host.open(to.href, options); + } + + const location: ShimLocation = { + get href() { + return current().url; + }, + set href(value: string) { + navigate(value, {}); + }, + get origin() { + return currentUrl().origin; + }, + get protocol() { + return currentUrl().protocol; + }, + get host() { + return currentUrl().host; + }, + get hostname() { + return currentUrl().hostname; + }, + get port() { + return currentUrl().port; + }, + get pathname() { + return currentUrl().pathname; + }, + set pathname(value: string) { + const url = currentUrl(); + url.pathname = value; + navigate(url.href, {}); + }, + get search() { + return currentUrl().search; + }, + set search(value: string) { + const url = currentUrl(); + url.search = value; + navigate(url.href, {}); + }, + get hash() { + return currentUrl().hash; + }, + set hash(value: string) { + const url = currentUrl(); + url.hash = value.startsWith('#') ? value : `#${value}`; + if (url.href !== current().url) { + navigate(url.href, {}); + } + }, + assign(url: string) { + navigate(url, {}); + }, + replace(url: string) { + navigate(url, { replace: true }); + }, + reload() { + if (host.reload) { + host.reload(); + } else { + host.open(current().url, { replace: true }); + } + }, + toString() { + return current().url; + }, + }; + + // --------------------------------------------------------------------- + // document / window + // --------------------------------------------------------------------- + + const documentLike: ShimDocument = { + visibilityState: 'visible', + addEventListener: (type, listener) => documentEvents.addEventListener(type, listener), + removeEventListener: (type, listener) => documentEvents.removeEventListener(type, listener), + dispatchEvent: (event) => documentEvents.dispatchEvent(event), + querySelector: () => null, + getElementById: () => null, + }; + + const windowLike: ShimWindow = { + window: undefined as unknown as ShimWindow, // self-reference set below + history, + location, + document: documentLike, + navigator: {}, + scrollX: 0, + scrollY: 0, + scrollTo() { + // No viewport to scroll; native hosts own scrolling. + }, + open(url?: string, _target?: string, _features?: string) { + if (url) { + host.open(resolve(url).href, { newWindow: true }); + } + return null; + }, + requestAnimationFrame(callback: (time: number) => void) { + return setTimeout(() => callback(Date.now()), 16); + }, + cancelAnimationFrame(handle: ReturnType) { + clearTimeout(handle); + }, + addEventListener: (type, listener) => windowEvents.addEventListener(type, listener), + removeEventListener: (type, listener) => windowEvents.removeEventListener(type, listener), + dispatchEvent: (event) => windowEvents.dispatchEvent(event), + }; + windowLike.window = windowLike; + + function install(target: Record = globalThis as unknown as Record, options: { force?: boolean } = {}): void { + const define = (key: string, value: unknown) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + const present = descriptor !== undefined && target[key] !== undefined; + if (!options.force && present) return; + // Some hosts expose read-only globals (e.g. Node's `navigator` + // getter). Assign when writable, else redefine, else skip — never + // throw, so installing over a partially-populated context is safe. + try { + if (descriptor && !descriptor.configurable && !descriptor.writable && !descriptor.set) { + return; + } + if (descriptor && (descriptor.writable || descriptor.set)) { + target[key] = value; + } else { + Object.defineProperty(target, key, { value, configurable: true, writable: true }); + } + } catch { + // Read-only, non-configurable global: leave the host's version. + } + }; + define('window', windowLike); + define('self', windowLike); + define('history', history); + define('location', location); + define('document', documentLike); + define('navigator', windowLike.navigator); + define('requestAnimationFrame', windowLike.requestAnimationFrame); + define('cancelAnimationFrame', windowLike.cancelAnimationFrame); + define('addEventListener', windowLike.addEventListener); + define('removeEventListener', windowLike.removeEventListener); + define('dispatchEvent', windowLike.dispatchEvent); + define('scrollX', 0); + define('scrollY', 0); + define('scrollTo', windowLike.scrollTo); + define('open', windowLike.open); + } + + function notifyPageHide(): void { + documentLike.visibilityState = 'hidden'; + documentEvents.dispatchEvent({ type: 'visibilitychange' }); + windowEvents.dispatchEvent({ type: 'pagehide' }); + } + + return { + window: windowLike, + history, + location, + document: documentLike, + install, + notifyPageHide, + get entries() { + return entries as readonly ShimHistoryEntry[]; + }, + get index() { + return index; + }, + }; +} diff --git a/packages/web-navigation-shim/src/types.ts b/packages/web-navigation-shim/src/types.ts new file mode 100644 index 00000000..e50b1298 --- /dev/null +++ b/packages/web-navigation-shim/src/types.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Options passed to {@link NavigationHost.open} when the shim decides a + * navigation must leave the current document. + */ +export interface HostOpenOptions { + /** + * Replace the current document in the host stack instead of pushing a + * new one (`location.replace` semantics). + */ + replace?: boolean; + /** + * The navigation was requested as an auxiliary browsing context + * (`window.open`). Hosts without a window concept may treat it as a push. + */ + newWindow?: boolean; +} + +/** + * The contract a platform must implement for the shim to drive it. + * + * This is intentionally tiny and framework-free: a host knows how to + * (a) say which URL the current document was opened at, (b) open a new + * document, and (c) close the current one. Everything else — the + * synthetic same-document history stack, `popstate` dispatch, `location` + * semantics — is provided by the shim on top. + * + * Implementations in this repo: + * - `web-navigation-shim/memory` — in-memory document stack (tests, SSR) + * - `sparkling-navigation/shim-host` — Sparkling container stack driven + * by `router.open` / `router.close` with sparkling scheme URLs. + * + * Any other native shell (or framework) can participate by implementing + * this interface — nothing in the shim references Sparkling or Lynx. + */ +export interface NavigationHost { + /** + * Absolute, WHATWG-parsable URL this document was opened at + * (e.g. `https://app.local/users/42?tab=posts`). The shim seeds + * `location` and the history stack from it. + */ + readonly initialUrl: string; + + /** + * Cross-document navigation: open `url` as a new document in the host + * stack (native push). The current JS context usually survives in the + * background (native stacks keep previous pages alive), so this MUST NOT + * assume teardown. + */ + open(url: string, options?: HostOpenOptions): void; + + /** + * Cross-document back: close this document (native pop). May tear down + * the current JS context. + */ + close(): void; + + /** + * Optional: traverse the host stack by `delta` documents (negative = + * back). Called when a `history.go()` crosses the bottom of the local + * synthetic stack. Defaults to `close()` per step for negative deltas; + * positive deltas are ignored (forward across documents is not a native + * stack concept). + */ + go?(delta: number): void; + + /** + * Optional: reload the current document. Backs `location.reload()`. + * Defaults to `open(currentUrl, { replace: true })`. + */ + reload?(): void; + + /** + * Optional policy: decide whether navigating from `from` to `to` stays + * inside the current document (synthetic history entry + `popstate`) or + * must open a new document via {@link open}. + * + * Default policy mirrors the real web: only hash-only changes are + * same-document; any other `location.assign` leaves the document. + * A Sparkling host can override this using its route manifest (e.g. + * "same Lynx bundle" = same document). + */ + isSameDocument?(from: URL, to: URL): boolean; +} + +/** + * A history entry in the synthetic (same-document) stack. + */ +export interface ShimHistoryEntry { + url: string; + state: unknown; +} + +/** + * Minimal `PopStateEvent`-compatible shape dispatched by the shim. + */ +export interface ShimPopStateEvent { + type: 'popstate'; + state: unknown; + /** Points at the shim window, like `event.target === window` on the web. */ + target: unknown; + hasUAVisualTransition: boolean; + defaultPrevented: boolean; + preventDefault(): void; +} + +export interface ShimEventListener { + (event: unknown): void; +} diff --git a/packages/web-navigation-shim/tsconfig.json b/packages/web-navigation-shim/tsconfig.json new file mode 100644 index 00000000..fc9ba3b0 --- /dev/null +++ b/packages/web-navigation-shim/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": false + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/__tests__/**" + ] +} diff --git a/packages/web-navigation-shim/vitest.config.ts b/packages/web-navigation-shim/vitest.config.ts new file mode 100644 index 00000000..0ab0b8f7 --- /dev/null +++ b/packages/web-navigation-shim/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + // vue-router only needs @vue/runtime-core APIs. The full `vue` build + // (runtime-dom) touches `document.createElement` at import time, + // which neither Node nor a Lynx JS context provides — the same + // reason Lynx renderers build on runtime-core. + vue: '@vue/runtime-core', + }, + }, + test: { + // Deliberately plain Node (no jsdom): the shim must be self-sufficient + // in a non-browser JS context, which is exactly the Lynx situation. + environment: 'node', + include: ['__tests__/**/*.spec.ts'], + server: { + deps: { + // Route vue-router through the Vite pipeline so the `vue` alias + // above applies to its imports as well. + inline: ['vue-router'], + }, + }, + }, +}); diff --git a/packages/website/rspress.config.ts b/packages/website/rspress.config.ts index 1a75fd6d..1154494d 100644 --- a/packages/website/rspress.config.ts +++ b/packages/website/rspress.config.ts @@ -52,6 +52,16 @@ const sidebarEn = { { dividerType: 'solid' }, { sectionHeaderText: 'Native Module' }, { text: 'Custom Methods', link: '/guide/get-started/create-custom-method' }, + { dividerType: 'solid' }, + { sectionHeaderText: 'Web Platform' }, + { text: 'Web Platform Guide', link: '/guide/web-platform' }, + { text: 'Web Methods', link: '/guide/web-method-implementations' }, + { text: 'Limitations', link: '/guide/web-limitations' }, + { dividerType: 'solid' }, + { sectionHeaderText: 'Nuxt (MPA)' }, + { text: 'Compatibility', link: '/guide/nuxt-sparkling-compat' }, + { text: 'Web API Dependencies', link: '/guide/nuxt-web-api-dependencies' }, + { text: 'Further Portability', link: '/guide/nuxt-sparkling-further-portability' }, ], '/apis/': [ { text: 'Overview', link: '/apis/' }, @@ -122,6 +132,11 @@ const sidebarZhBase = { { dividerType: 'solid' }, { sectionHeaderText: '原生模块' }, { text: '自定义 Method', link: '/guide/get-started/create-custom-method' }, + { dividerType: 'solid' }, + { sectionHeaderText: 'Web 平台' }, + { text: 'Web 平台指南', link: '/guide/web-platform' }, + { text: 'Web Methods', link: '/guide/web-method-implementations' }, + { text: '限制', link: '/guide/web-limitations' }, ], '/apis/': [ { text: '概览', link: '/apis/' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22316010..308aaca1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + playwright: + specifier: ^1.58.2 + version: 1.59.1 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -100,6 +103,31 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/nuxt-sparkling: + dependencies: + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + web-navigation-shim: + specifier: workspace:* + version: link:../web-navigation-shim + devDependencies: + '@vue/runtime-core': + specifier: ^3.5.13 + version: 3.5.39 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) + vue: + specifier: ^3.5.13 + version: 3.5.39(typescript@5.9.3) + vue-router: + specifier: ^5.1.0 + version: 5.1.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@vue/compiler-sfc@3.5.39)(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.39(typescript@5.9.3))(webpack@5.105.0(esbuild@0.27.7)) + packages/playground: dependencies: '@lynx-js/react': @@ -126,7 +154,7 @@ importers: version: 0.4.6 '@lynx-js/react-rsbuild-plugin': specifier: ^0.12.7 - version: 0.12.10(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0) + version: 0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0) '@lynx-js/rspeedy': specifier: ^0.13.3 version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0) @@ -144,7 +172,7 @@ importers: version: 18.3.28 '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -162,7 +190,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.2 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) packages/sparkling-app-cli: dependencies: @@ -278,6 +306,52 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/sparkling-web-shell: + dependencies: + '@lynx-js/web-core': + specifier: 0.19.8 + version: 0.19.8(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.11.3(tslib@2.8.1)) + '@lynx-js/web-elements': + specifier: 0.11.3 + version: 0.11.3(tslib@2.8.1) + '@rsbuild/core': + specifier: 1.7.2 + version: 1.7.2 + sparkling-media: + specifier: workspace:* + version: link:../methods/sparkling-media + sparkling-method: + specifier: workspace:* + version: link:../sparkling-method + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + sparkling-storage: + specifier: workspace:* + version: link:../methods/sparkling-storage + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/web-navigation-shim: + devDependencies: + '@vue/runtime-core': + specifier: ^3.5.13 + version: 3.5.39 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) + vue: + specifier: ^3.5.13 + version: 3.5.39(typescript@5.9.3) + vue-router: + specifier: ^5.1.0 + version: 5.1.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@vue/compiler-sfc@3.5.39)(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.39(typescript@5.9.3))(webpack@5.105.0(esbuild@0.27.7)) + packages/website: devDependencies: '@rspack/core': @@ -319,13 +393,19 @@ importers: version: 0.4.6 '@lynx-js/react-rsbuild-plugin': specifier: ^0.12.7 - version: 0.12.10(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0) + version: 0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0) '@lynx-js/rspeedy': specifier: ^0.13.3 version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0) '@lynx-js/types': specifier: ^3.7.0 version: 3.7.0 + '@lynx-js/web-core': + specifier: 0.19.8 + version: 0.19.8(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.11.3(tslib@2.8.1)) + '@lynx-js/web-elements': + specifier: 0.11.3 + version: 0.11.3(tslib@2.8.1) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -347,12 +427,15 @@ importers: sparkling-types: specifier: ~2.1.0-rc.12 version: 2.1.0-rc.20(@lynx-js/types@3.7.0) + sparkling-web-shell: + specifier: ~2.1.0-rc.12 + version: link:../../packages/sparkling-web-shell typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)) + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) packages: @@ -403,6 +486,10 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -425,14 +512,22 @@ packages: resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -446,6 +541,16 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -549,6 +654,14 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -934,6 +1047,12 @@ packages: '@lynx-js/css-serializer@0.1.4': resolution: {integrity: sha512-yG3sC1fH+rBQhbdvPCweaGjmFmOBdi2rbt7xs9laMfu5Z5dOXL1OB7pZZN0vodptBO0/ctVfMCKH2EKhq0GtTA==} + '@lynx-js/lynx-core@0.1.3': + resolution: {integrity: sha512-uWzKKYJUK4Q09ZRZxWSAFINnmZb9piWPbvWF9SkLn+3snBl9u/BJa4ekPRcKWAhBmpbtxWH1x27fxe3Q3p5l3Q==} + + '@lynx-js/offscreen-document@0.1.4': + resolution: {integrity: sha512-7OUAXZbpWigxOwpcDUU348R9Iw4JZP8qOsNdJ7+Z+LWTWt2DxbkxIKJ/KawQNOp+tsXeOFwUNbrgGkA7aqAX5w==} + '@lynx-js/qrcode-rsbuild-plugin@0.4.6': resolution: {integrity: sha512-KHnvkccapEWEtfRMJ4GxNoZDsv8c0RlfNfc3la/z8G2eP+vb8VtKG1Atk2FVagUVnJcuePeuue8LaivzoM/Irw==} engines: {node: '>=18'} @@ -1005,6 +1124,9 @@ packages: peerDependencies: '@lynx-js/react': '*' + '@lynx-js/web-constants@0.19.8': + resolution: {integrity: sha512-aOk2wmwNu0jJ4ERqU+FBva20uZsQi3Uw1uRwOw8pv3fnetNhW5BlExuGdUzv6XTHbOEd1Jfm4jiOwjrzgXr0PQ==} + '@lynx-js/web-core-wasm@0.0.5': resolution: {integrity: sha512-ICvkMf9Myx8/eShv3oCXp9/u+u7yLMC7WTDtHDEZXGyLVkT4dQqtevw7AfOgihu8PQKncOfeMPzO2csFKDejzw==} peerDependencies: @@ -1019,17 +1141,36 @@ packages: tslib: optional: true + '@lynx-js/web-core@0.19.8': + resolution: {integrity: sha512-8J+T7lZYraWcJNEWWLeVWjeUhRaIRPS7XL6l1x5A+5SC8QCNIezUl1fSYIJiRRQLZjph2xXvBn2K5vzQ3PJREw==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/web-elements': '>0.7.7' + + '@lynx-js/web-elements@0.11.3': + resolution: {integrity: sha512-GmfJ1pyvIyR2BpFaWYlH/zlVujVMuCUj+rK20vxlza1wQdSsc5wOeBHbbY3DGq73DvtkhPEczKqIncRUQGZaMA==} + peerDependencies: + tslib: ^2.5.0 + '@lynx-js/web-elements@0.12.0': resolution: {integrity: sha512-7z8PQQSDMUWrxoizySOg1pKGIiSDeGGomOr5FV0Tx9gniHNJYx0e7/h6wQmwhyHdAZYyihyaBks88QkX8DRNEw==} peerDependencies: tslib: ^2.5.0 + '@lynx-js/web-mainthread-apis@0.19.8': + resolution: {integrity: sha512-Ze6u1wg5VZO1htXG6AVRB6CSf1qDcMrxtyl22BMmQhRvm9ut4Ck8k1+e5MIaUCTST1q1j0s59bsKsSstZ/Xlng==} + '@lynx-js/web-rsbuild-server-middleware@0.19.9': resolution: {integrity: sha512-iOqUxrXUD6L6ot01AMb9VXdR/Twh/e6t4SRKFC1NafIw99gUHumVsQHUGh5oZQLtP7mZHJMkeBcavzqvfcW58w==} '@lynx-js/web-worker-rpc@0.19.8': resolution: {integrity: sha512-POkpPZQjU+a0V/LGWNkaqy+/E6B2WEDYnbh+0FtRNLXcNOBOcgvZNg0fo6VVbw3UInmTwO0v+2g/kMjotizwkg==} + '@lynx-js/web-worker-runtime@0.19.8': + resolution: {integrity: sha512-lZHnEWQ0QrNIHRGUzYBS22d4ckwyUM+1Gee8D2pCYSAdyqr4aRaNJrUEp8sO8YTh91kohN3c09YQcgRZ8sIstQ==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/webpack-dev-transport@0.2.0': resolution: {integrity: sha512-RSy02FSoMsavEn2wna4khSJwT2uGW4XeLduKH8UDT3aCsBNM/rXndUyG6PbwMcywXCeyb8UofkhaQDtJvNJklA==} engines: {node: '>=18'} @@ -1750,6 +1891,9 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1891,6 +2035,53 @@ packages: '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/devtools-api@8.1.5': + resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==} + + '@vue/devtools-kit@8.1.5': + resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==} + + '@vue/devtools-shared@8.1.5': + resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2048,12 +2239,20 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -2122,6 +2321,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2245,6 +2447,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -2308,6 +2514,12 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} @@ -2631,6 +2843,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + envinfo@7.14.0: resolution: {integrity: sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==} engines: {node: '>=4'} @@ -2737,6 +2953,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2763,6 +2982,9 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2996,6 +3218,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3032,6 +3257,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3474,6 +3702,10 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3509,6 +3741,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3792,6 +4028,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3802,6 +4041,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -3811,6 +4053,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3967,6 +4214,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3986,6 +4236,12 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -4173,6 +4429,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -4208,6 +4468,9 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4269,6 +4532,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -4553,6 +4820,9 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5033,6 +5303,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -5090,6 +5363,43 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5240,6 +5550,32 @@ packages: jsdom: optional: true + vue-router@5.1.0: + resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 + pinia: ^3.0.4 + vite: ^7.0.0 || ^8.0.0 + vue: ^3.5.34 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -5247,6 +5583,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -5266,6 +5605,9 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webpack@5.105.0: resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==} engines: {node: '>=10.13.0'} @@ -5384,6 +5726,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5444,13 +5791,13 @@ snapshots: '@babel/code-frame@7.26.2': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -5463,10 +5810,10 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -5478,12 +5825,21 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5497,7 +5853,7 @@ snapshots: '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -5505,27 +5861,39 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-string-parser@8.0.0': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.4': {} '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.2': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: @@ -5615,25 +5983,35 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color '@babel/types@7.29.0': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 '@bcoe/v8-coverage@0.2.3': {} @@ -6058,9 +6436,9 @@ snapshots: dependencies: '@lynx-js/webpack-runtime-globals': 0.0.6 - '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1))(webpack@5.105.0)': + '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0)': dependencies: - '@lynx-js/template-webpack-plugin': 0.10.5(tslib@2.8.1) + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) mini-css-extract-plugin: 2.10.2(webpack@5.105.0) transitivePeerDependencies: - webpack @@ -6069,22 +6447,26 @@ snapshots: dependencies: css-tree: 3.2.1 + '@lynx-js/lynx-core@0.1.3': {} + + '@lynx-js/offscreen-document@0.1.4': {} + '@lynx-js/qrcode-rsbuild-plugin@0.4.6': {} '@lynx-js/react-alias-rsbuild-plugin@0.12.10': {} - '@lynx-js/react-refresh-webpack-plugin@0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1)))': + '@lynx-js/react-refresh-webpack-plugin@0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)))': dependencies: - '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1)) + '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) - '@lynx-js/react-rsbuild-plugin@0.12.10(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0)': + '@lynx-js/react-rsbuild-plugin@0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0)': dependencies: - '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1))(webpack@5.105.0) + '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0) '@lynx-js/react-alias-rsbuild-plugin': 0.12.10 - '@lynx-js/react-refresh-webpack-plugin': 0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1))) - '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1)) + '@lynx-js/react-refresh-webpack-plugin': 0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))) + '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) '@lynx-js/runtime-wrapper-webpack-plugin': 0.1.3 - '@lynx-js/template-webpack-plugin': 0.10.5(tslib@2.8.1) + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) '@lynx-js/use-sync-external-store': 1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28)) background-only: 0.0.1 optionalDependencies: @@ -6094,9 +6476,9 @@ snapshots: - tslib - webpack - '@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1))': + '@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))': dependencies: - '@lynx-js/template-webpack-plugin': 0.10.5(tslib@2.8.1) + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) '@lynx-js/webpack-runtime-globals': 0.0.6 tiny-invariant: 1.3.3 optionalDependencies: @@ -6141,11 +6523,11 @@ snapshots: '@lynx-js/tasm@0.0.26': {} - '@lynx-js/template-webpack-plugin@0.10.5(tslib@2.8.1)': + '@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)': dependencies: '@lynx-js/css-serializer': 0.1.4 '@lynx-js/tasm': 0.0.26 - '@lynx-js/web-core-wasm': 0.0.5(@lynx-js/css-serializer@0.1.4)(tslib@2.8.1) + '@lynx-js/web-core-wasm': 0.0.5(@lynx-js/css-serializer@0.1.4)(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) '@lynx-js/webpack-runtime-globals': 0.0.6 '@rspack/lite-tapable': 1.1.0 css-tree: 3.2.1 @@ -6162,22 +6544,55 @@ snapshots: dependencies: '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) - '@lynx-js/web-core-wasm@0.0.5(@lynx-js/css-serializer@0.1.4)(tslib@2.8.1)': + '@lynx-js/web-constants@0.19.8': + dependencies: + '@lynx-js/web-worker-rpc': 0.19.8 + + '@lynx-js/web-core-wasm@0.0.5(@lynx-js/css-serializer@0.1.4)(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)': dependencies: '@lynx-js/web-elements': 0.12.0(tslib@2.8.1) '@lynx-js/web-worker-rpc': 0.19.8 optionalDependencies: '@lynx-js/css-serializer': 0.1.4 + '@lynx-js/lynx-core': 0.1.3 + tslib: 2.8.1 + + '@lynx-js/web-core@0.19.8(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.11.3(tslib@2.8.1))': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/offscreen-document': 0.1.4 + '@lynx-js/web-constants': 0.19.8 + '@lynx-js/web-elements': 0.11.3(tslib@2.8.1) + '@lynx-js/web-mainthread-apis': 0.19.8 + '@lynx-js/web-worker-rpc': 0.19.8 + '@lynx-js/web-worker-runtime': 0.19.8(@lynx-js/lynx-core@0.1.3) + + '@lynx-js/web-elements@0.11.3(tslib@2.8.1)': + dependencies: tslib: 2.8.1 '@lynx-js/web-elements@0.12.0(tslib@2.8.1)': dependencies: tslib: 2.8.1 + '@lynx-js/web-mainthread-apis@0.19.8': + dependencies: + '@lynx-js/web-constants': 0.19.8 + hyphenate-style-name: 1.1.0 + wasm-feature-detect: 1.8.0 + '@lynx-js/web-rsbuild-server-middleware@0.19.9': {} '@lynx-js/web-worker-rpc@0.19.8': {} + '@lynx-js/web-worker-runtime@0.19.8(@lynx-js/lynx-core@0.1.3)': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/offscreen-document': 0.1.4 + '@lynx-js/web-constants': 0.19.8 + '@lynx-js/web-mainthread-apis': 0.19.8 + '@lynx-js/web-worker-rpc': 0.19.8 + '@lynx-js/webpack-dev-transport@0.2.0': {} '@lynx-js/webpack-runtime-globals@0.0.6': {} @@ -6959,24 +7374,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -7043,6 +7458,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/jsonfile@6.1.4': @@ -7107,7 +7524,7 @@ snapshots: react: 19.2.5 unhead: 2.1.13 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -7122,7 +7539,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7138,7 +7555,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) '@vitest/expect@3.2.4': dependencies: @@ -7157,21 +7574,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) - '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7223,6 +7640,83 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue-macros/common@3.1.2(vue@3.5.39(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.39 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.39(typescript@5.9.3) + + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.17 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/devtools-api@8.1.5': + dependencies: + '@vue/devtools-kit': 8.1.5 + + '@vue/devtools-kit@8.1.5': + dependencies: + '@vue/devtools-shared': 8.1.5 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.5': {} + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@5.9.3) + + '@vue/shared@3.5.39': {} + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -7396,6 +7890,11 @@ snapshots: assertion-error@2.0.1: {} + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.7 + pathe: 2.0.3 + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -7408,6 +7907,12 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ast-kit: 2.2.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -7452,7 +7957,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -7497,6 +8002,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + birpc@2.9.0: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -7629,6 +8136,10 @@ snapshots: readdirp: 4.1.2 optional: true + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -7674,6 +8185,10 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + + confbox@0.2.4: {} + connect@3.7.0: dependencies: debug: 2.6.9 @@ -8012,6 +8527,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + envinfo@7.14.0: {} error-ex@1.3.4: @@ -8199,6 +8716,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -8231,6 +8750,8 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + exsolve@1.1.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -8552,6 +9073,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hookable@5.5.3: {} + hookable@6.1.1: {} html-encoding-sniffer@4.0.0: @@ -8599,6 +9122,8 @@ snapshots: human-signals@2.1.0: {} + hyphenate-style-name@1.1.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -8782,7 +9307,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -8792,7 +9317,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 7.7.4 @@ -9155,7 +9680,7 @@ snapshots: '@babel/generator': 7.29.1 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 @@ -9341,6 +9866,12 @@ snapshots: loader-runner@4.3.1: {} + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9367,6 +9898,10 @@ snapshots: lunr@2.3.9: {} + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9379,8 +9914,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: @@ -9918,16 +10453,27 @@ snapshots: minipass@7.1.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + mrmime@2.0.1: {} ms@2.0.0: {} ms@2.1.3: {} + muggle-string@0.4.1: {} + mustache@4.2.0: {} nanoid@3.3.11: {} + nanoid@3.3.15: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -10082,6 +10628,8 @@ snapshots: pathval@2.0.1: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -10094,6 +10642,18 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + playwright-core@1.59.1: {} playwright@1.59.1: @@ -10266,6 +10826,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prettier@3.8.3: {} pretty-format@29.7.0: @@ -10293,6 +10859,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quansync@0.2.11: {} + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -10346,6 +10914,8 @@ snapshots: readdirp@4.1.2: optional: true + readdirp@5.0.0: {} + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -10709,6 +11279,8 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 + scule@1.3.0: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -11038,6 +11610,17 @@ snapshots: tapable@2.3.2: {} + terser-webpack-plugin@5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.105.0(esbuild@0.27.7) + optionalDependencies: + esbuild: 0.27.7 + optional: true + terser-webpack-plugin@5.4.0(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -11271,6 +11854,8 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.6.4: {} + uglify-js@3.19.3: optional: true @@ -11342,6 +11927,23 @@ snapshots: unpipe@1.0.0: {} + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin@3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + esbuild: 0.27.7 + rollup: 4.60.1 + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) + webpack: 5.105.0(esbuild@0.27.7) + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -11380,13 +11982,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -11401,7 +12003,7 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -11416,13 +12018,13 @@ snapshots: sass: 1.97.3 sass-embedded: 1.97.3 terser: 5.46.1 - yaml: 2.8.3 + yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11440,8 +12042,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -11461,10 +12063,10 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)): + vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -11481,7 +12083,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 @@ -11490,6 +12092,49 @@ snapshots: transitivePeerDependencies: - msw + vue-router@5.1.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@vue/compiler-sfc@3.5.39)(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))(vue@3.5.39(typescript@5.9.3))(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@babel/generator': 8.0.0 + '@vue-macros/common': 3.1.2(vue@3.5.39(typescript@5.9.3)) + '@vue/devtools-api': 8.1.5 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.16 + unplugin: 3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0))(webpack@5.105.0(esbuild@0.27.7)) + unplugin-utils: 0.3.2 + vue: 3.5.39(typescript@5.9.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.39 + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + + vue@3.5.39(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@5.9.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 5.9.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -11498,6 +12143,8 @@ snapshots: dependencies: makeerror: 1.0.12 + wasm-feature-detect@1.8.0: {} + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -11511,6 +12158,8 @@ snapshots: webpack-sources@3.3.4: {} + webpack-virtual-modules@0.6.2: {} + webpack@5.105.0: dependencies: '@types/eslint-scope': 3.7.7 @@ -11543,6 +12192,39 @@ snapshots: - esbuild - uglify-js + webpack@5.105.0(esbuild@0.27.7): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + optional: true + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -11649,6 +12331,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/template/sparkling-app-template/app.config.ts b/template/sparkling-app-template/app.config.ts index 03eedf13..38c128dd 100644 --- a/template/sparkling-app-template/app.config.ts +++ b/template/sparkling-app-template/app.config.ts @@ -12,9 +12,23 @@ const lynxConfig = defineConfig({ }, }, output: { - assetPrefix: 'asset:///', filename: { - bundle: '[name].lynx.bundle' + bundle: '[name].lynx.bundle', + }, + }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: { + output: { + assetPrefix: 'asset:///', + }, }, }, plugins: [ diff --git a/template/sparkling-app-template/package.json b/template/sparkling-app-template/package.json index 4079c5b3..0da85b6b 100644 --- a/template/sparkling-app-template/package.json +++ b/template/sparkling-app-template/package.json @@ -12,6 +12,9 @@ "scripts": { "dev": "sparkling-app-cli dev", "build": "sparkling-app-cli build --copy", + "build:web": "sparkling-app-cli build --platform web", + "dev:web": "sparkling-app-cli dev --platform web", + "run:web": "sparkling-app-cli run:web", "autolink": "sparkling-app-cli autolink", "test": "vitest run --passWithNoTests", "run:android": "sparkling-app-cli run:android", @@ -27,7 +30,10 @@ "@lynx-js/react-rsbuild-plugin": "^0.12.7", "@lynx-js/rspeedy": "^0.13.3", "@lynx-js/types": "^3.7.0", + "@lynx-js/web-core": "0.19.8", + "@lynx-js/web-elements": "0.11.3", "sparkling-types": "~2.1.0-rc.12", + "sparkling-web-shell": "~2.1.0-rc.12", "@testing-library/jest-dom": "^6.9.1", "@types/react": "^18.3.20", "@vitest/coverage-v8": "^4.0.18",