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..8ff0fe06 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 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/examples.mdx b/docs/en/guide/examples.mdx index e13a8e5e..e90a9917 100644 --- a/docs/en/guide/examples.mdx +++ b/docs/en/guide/examples.mdx @@ -21,6 +21,13 @@ A minimal Sparkling Lynx page with a tap counter. +## Vue Router (MPA) + +[Vue Router](/guide/examples/vue-router) driving Sparkling's multi-page native +navigation: one router config, multiple native Lynx pages. In-heap (SPA) route +transitions run live in the web preview; cross-page (MPA) navigation opens +native containers on device. + ## Sparkling Go demos The [Sparkling Go playground](https://github.com/tiktok/sparkling/tree/main/packages/playground) diff --git a/docs/en/guide/examples/vue-router.mdx b/docs/en/guide/examples/vue-router.mdx new file mode 100644 index 00000000..22793dbf --- /dev/null +++ b/docs/en/guide/examples/vue-router.mdx @@ -0,0 +1,95 @@ +# Vue Router (MPA) + +This example runs **[Vue Router](https://router.vuejs.org) on top of Sparkling +navigation**, so a single router configuration drives navigation across +**multiple native Lynx pages** — the multi-page (MPA) model. + +It is built on [`sparkling-history`](https://github.com/tiktok/sparkling/tree/main/packages/sparkling-history), +a framework-agnostic shim that implements Vue Router's history contract on top +of Sparkling's `router.open` / `router.close`. + +:::info Different from VueLynx's in-view router +[VueLynx](https://vue.lynxjs.org) supports Vue Router with `createMemoryHistory` +to build an SPA **inside a single `LynxView`** (one JS heap). This example is +the opposite: each page is a **separate Lynx container in its own JS heap**, +and Vue Router drives *native* navigation between them. The heaps share no +memory — only a build-time route manifest connects them. +::: + +## In-heap navigation (live in the web preview) + +The `main` bundle owns two routes, `/` and `/features`. Navigating between them +is an ordinary Vue Router transition **inside one heap** — no native bridge is +involved — so it runs live in the web preview below. Tap **Features** (or the +in-container button) and watch the route change without opening a new page. + + + +:::tip This goes beyond the other demos +Sparkling's other web previews render UI only — their native calls are inert in +the browser. Here the in-heap route transitions actually work on the web, +because that regime is pure Vue Router. +::: + +## Dynamic routes (the `users` bundle) + +The `users` bundle owns `/users` and the dynamic `/users/:id`. Tapping a user +is an in-heap navigation with a route param, resolved entirely in this bundle's +router. This preview boots at the bundle's own default route (`/users`). + + + +## Cross-page navigation (MPA — live demo) + +When `router.push` resolves to a route owned by **another** bundle (per the +route manifest), the shim diverts it to `router.open`, which stacks a **new +native container**. The previous page stays alive underneath, exactly like a +native navigation stack. + +The `` previews above render one bundle at a time with no native bridge, so +cross-page navigation can't run in them. The demo below is different: it opens +the **Sparkling web shell** in a new tab — the shell stacks a `` per +container, provides the `spkPipe` method bridge, and installs a +`RouterWebHost`, so `router.open` actually opens a new container **in your +browser**. Tap **push('/users')** and a second container **slides in** and +stacks on top (the header's `depth` becomes 1); the **browser back button** +slides it back out, because the shell owns the whole tab and drives real +browser history. (The slide is a native-style transition the shell applies on +push/pop; it honors `prefers-reduced-motion`.) + + + +You can also **scan the QR code** on any `` preview to run it in Lynx +Explorer (with Sparkling integrated), where `router.open` runs natively. + +:::note Why the `` previews can't cross pages, but the web shell can +In-heap navigation is pure Vue Router in the card's own JS heap — no native +call — so it runs in any preview. Cross-page navigation calls `router.open`, +which needs a native host. `sparkling-navigation` exposes a pluggable +`RouterWebHost`, but go-web's `` preview doesn't yet expose a hook to +register it (or the `spkPipe` bridge) on its ``. The web shell owns +its whole tab, so it installs that host and bridge itself — which is why +cross-page navigation works there. +::: + +## How it maps + +| Vue Router | Sparkling | +|---|---| +| route owned by the current bundle | in-heap transition (SPA) | +| route owned by another bundle | `router.open()` — new container (MPA) | +| `router.back()` past the local history | `router.close()` — pop the container | +| `history.state` | carried across heaps inside the scheme URL | +| history mode (`createWebHistory`/…) | replaced by `createHybridHistory` | + +For the complete traversal of Vue Router's feature set — what is supported, +reframed, or limited under the MPA model — see the +[compatibility matrix](https://github.com/tiktok/sparkling/blob/main/packages/sparkling-history/COMPATIBILITY.md). 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..6ce99e6c 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 @@ -48,6 +51,7 @@ npx sparkling build - A Lynx app project (based on the default template) - Android and iOS native shells already wired for Sparkling +- Web platform support for browser previews (enabled by default) - A working JS ↔ native pipe method example (router) - Developer workflow commands (build / autolink / run) @@ -56,7 +60,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/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/docs/zh/guide/examples.mdx b/docs/zh/guide/examples.mdx index 46f5cd1c..f9ba8c1a 100644 --- a/docs/zh/guide/examples.mdx +++ b/docs/zh/guide/examples.mdx @@ -20,6 +20,12 @@ Sparkling 提供了一组可运行的 Lynx 示例。每个示例都通过 +## Vue Router(MPA) + +[Vue Router](/guide/examples/vue-router) 驱动 Sparkling 的多页原生导航:一份路由配置, +多个原生 Lynx 页面。堆内(SPA)路由切换可在网页预览中实时运行;跨页(MPA)导航则在真机上 +打开原生容器。 + ## Sparkling Go 示例 [Sparkling Go playground](https://github.com/tiktok/sparkling/tree/main/packages/playground) diff --git a/docs/zh/guide/examples/vue-router.mdx b/docs/zh/guide/examples/vue-router.mdx new file mode 100644 index 00000000..8b9b500c --- /dev/null +++ b/docs/zh/guide/examples/vue-router.mdx @@ -0,0 +1,81 @@ +# Vue Router(MPA) + +本示例让 **[Vue Router](https://router.vuejs.org) 运行在 Sparkling 导航之上**, +从而用一份路由配置驱动**多个原生 Lynx 页面**之间的导航 —— 即多页(MPA)模型。 + +它基于 [`sparkling-history`](https://github.com/tiktok/sparkling/tree/main/packages/sparkling-history): +一个与框架无关的 shim,在 Sparkling 的 `router.open` / `router.close` 之上实现了 +Vue Router 的 history 契约。 + +:::info 与 VueLynx 的视图内路由不同 +[VueLynx](https://vue.lynxjs.org) 通过 `createMemoryHistory` 在**单个 `LynxView` +内**(同一个 JS heap)构建 SPA。本示例正相反:每个页面是一个**独立的 Lynx 容器、拥有 +各自的 JS heap**,Vue Router 驱动它们之间的*原生*导航。这些 heap 不共享内存 —— 仅靠一份 +构建期生成的路由清单相互连接。 +::: + +## 堆内导航(网页预览中可实时运行) + +`main` bundle 拥有两个路由 `/` 与 `/features`。在两者间切换是发生在**同一个 heap 内**的 +普通 Vue Router 过渡,不涉及原生桥,因此可在下方的网页预览中实时运行。点击 **Features** +(或页内按钮),可见路由在不打开新页面的情况下发生变化。 + + + +:::tip 这一点超越了其他示例 +Sparkling 的其他网页预览只渲染 UI —— 其原生调用在浏览器中是失效的。而这里堆内的路由过渡在 +网页上确实可用,因为这一部分是纯粹的 Vue Router。 +::: + +## 动态路由(`users` bundle) + +`users` bundle 拥有 `/users` 与动态的 `/users/:id`。点击某个用户是带路由参数的堆内导航, +完全由该 bundle 自己的 router 解析。此预览从该 bundle 自身的默认路由(`/users`)启动。 + + + +## 跨页导航(MPA —— 实时体验) + +当 `router.push` 解析到**另一个** bundle 拥有的路由(依据路由清单)时,shim 会将其转发到 +`router.open`,从而压入一个**新的原生容器**。上一个页面仍存活于其下方,与原生导航栈完全一致。 + +上方的 `` 预览一次只渲染一个 bundle、且没有原生桥,所以跨页导航跑不起来。下方这个 demo +不同:它会在**新标签页**打开 **Sparkling web shell**——为每个容器堆叠一个 ``、 +提供 `spkPipe` 方法桥、并安装 `RouterWebHost`——因此 `router.open` 会真的**在你的浏览器里** +打开一个新容器。点击 **push('/users')**,第二个容器会**滑入**并压栈(顶部 `depth` 变为 1), +而**浏览器返回键**会把它滑出——因为 shell 独占整个标签页,驱动的是真实的浏览器 history。 +(这个滑动是 shell 在 push/pop 时施加的原生风格过渡,并会遵循 `prefers-reduced-motion`。) + + + +你也可以在任意 `` 预览上**扫描二维码**,在(已集成 Sparkling 的)Lynx Explorer 中打开, +`router.open` 会原生运行。 + +:::note 为何 `` 预览跨不了页,而 web shell 可以 +堆内导航是卡片自身 JS heap 内的纯 Vue Router —— 没有原生调用 —— 因此在任何预览里都能跑。 +跨页导航调用 `router.open`,需要一个原生 host。`sparkling-navigation` 暴露了可插拔的 +`RouterWebHost`,但 go-web 的 `` 预览目前没有暴露在其 `` 上注册该 host +(或 `spkPipe` 桥)的钩子。而 web shell 独占它自己的整个标签页,会自行安装该 host 与桥—— +所以跨页导航在那里可用。 +::: + +## 映射关系 + +| Vue Router | Sparkling | +|---|---| +| 当前 bundle 拥有的路由 | 堆内过渡(SPA) | +| 其他 bundle 拥有的路由 | `router.open()` —— 新容器(MPA) | +| `router.back()` 越过本地历史 | `router.close()` —— 弹出容器 | +| `history.state` | 通过 scheme URL 在 heap 之间传递 | +| history 模式(`createWebHistory` 等) | 由 `createHybridHistory` 取代 | + +Vue Router 完整特性集在 MPA 模型下的支持、重构与限制情况,参见 +[兼容性矩阵](https://github.com/tiktok/sparkling/blob/main/packages/sparkling-history/COMPATIBILITY.md)。 diff --git a/examples/vue-router-mpa/.gitignore b/examples/vue-router-mpa/.gitignore new file mode 100644 index 00000000..e7173325 --- /dev/null +++ b/examples/vue-router-mpa/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +.rspeedy/ +*.log diff --git a/examples/vue-router-mpa/README.md b/examples/vue-router-mpa/README.md new file mode 100644 index 00000000..e7f490be --- /dev/null +++ b/examples/vue-router-mpa/README.md @@ -0,0 +1,79 @@ +# Vue Router × Sparkling MPA + +A Vue-Lynx example where **Vue Router drives Sparkling's multi-page native +navigation** through [`sparkling-history`](../../packages/sparkling-history). + +Unlike [VueLynx](https://vue.lynxjs.org)'s in-`LynxView` SPA routing (which +uses `createMemoryHistory` to keep every route in one JS heap), here each page +is a **separate Lynx container / JS heap**, connected by Sparkling +navigation — the MPA model. The connection between heaps is established at +build time via a file-based route manifest. + +## Layout + +``` +src/ + pages/ # file-based convention: one dir = one bundle = one container + main/ routes.json → "/", "/features" + users/ routes.json → "/users", "/users/:id" (name: user-detail) + settings/ routes.json → "/settings" (+ container param title=Settings) + shared/ + router.ts # createPageRouter(): one router per bundle + NavButton.vue + generated/ + route-manifest.ts # AUTO-GENERATED by sparkling-history/codegen +``` + +`lynx.config.ts` derives the rspeedy entries from `src/pages/*` and runs +`pluginRouteManifest`, which regenerates `src/generated/route-manifest.ts` +from every page's `routes.json` before each build. + +## What it demonstrates + +Each page bundle calls `createPageRouter(localRoutes)`. Navigations that +resolve to a route the current bundle owns stay **in-heap** (normal +Vue Router SPA: nested `RouterView`, params, guards). Navigations resolving +to a route owned by **another** bundle are diverted to +`router.open()` — a new native container. + +| Interaction | Kind | What happens | +|---|---|---| +| `push('/features')` from `main` | in-container | Vue Router transition, guards run, one container | +| `push('/users')` | cross-container | new `users` container stacked, `main` stays alive underneath | +| tap a user → `push('/users/1')` | in-container (users) | dynamic `:id` route inside the `users` heap | +| `push({ name: 'user-detail', params })` | cross-container | named route resolved via the manifest | +| `push({ path, state })` | cross-container | `history.state` carried across heaps via the scheme URL | +| `replace('/settings')` | cross-container | swaps the current container (same depth) | +| `back()` past the local queue | boundary | pops the native container; revealed page gets an `onRestore` (pageshow-like) event | + +## Web preview vs. device + +This example is embedded on the website with [``](https://github.com/lynx-community/go-web). +Because go-web previews **one bundle at a time** and doesn't provide +Sparkling's native container stack, the two navigation regimes show up +differently: + +- **In-heap (SPA) navigation is live in the web preview** — e.g. Home ↔ + Features on the `main` bundle is pure Vue Router with no native bridge, so + it works in the browser. This already goes beyond the other Sparkling demos, + whose native calls are inert on the web. +- **Cross-container (MPA) navigation needs the container stack** — tapping a + button that opens another bundle calls `router.open`, which requires a + Sparkling container. Use the **QR code** tab (Lynx Explorer on device) for + the full flow, or run the local web-shell harness below, which faithfully + simulates the native container stack in the browser. + +## Run the full flow on the local web-shell harness + +[`sparkling-web-shell`](../../packages/sparkling-web-shell) stacks a real +`` (its own Worker heap) per container, exactly like the native +stack, and provides the `router.open`/`router.close` bridge: + +```bash +pnpm --filter @sparkling-example/vue-router-mpa build +LYNX_BUNDLE_DIR="$PWD/examples/vue-router-mpa/dist" \ + pnpm --filter sparkling-web-shell dev +# open http://localhost:4200/?page=main +``` + +Deep-link straight into a route with `?page=users&__hs_route=/users/3`. diff --git a/examples/vue-router-mpa/lynx.config.ts b/examples/vue-router-mpa/lynx.config.ts new file mode 100644 index 00000000..0199570d --- /dev/null +++ b/examples/vue-router-mpa/lynx.config.ts @@ -0,0 +1,51 @@ +// 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 path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from '@lynx-js/rspeedy' +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin' +import { pluginVueLynx } from 'vue-lynx/plugin' +import { pluginRouteManifest } from 'sparkling-history/codegen' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const exampleName = path.basename(__dirname) +const pagesDir = path.join(__dirname, 'src/pages') + +export default defineConfig({ + // Build both a Lynx bundle (`*.lynx.bundle`, on-device / QR preview) and a + // web bundle (`*.web.bundle`, consumed by @lynx-js/web-core in the website's + // live preview). + environments: { + lynx: {}, + web: {}, + }, + // One entry per page bundle. Each becomes its own Lynx container / JS heap + // at runtime; each `src/pages//routes.json` declares which routes that + // bundle owns (see the generated route manifest below). + source: { + entry: { + main: './src/pages/main/index.ts', + users: './src/pages/users/index.ts', + settings: './src/pages/settings/index.ts', + }, + }, + output: { + // The website serves each example's `dist/` under this public path. + assetPrefix: `https://tiktok.github.io/sparkling/examples/${exampleName}/dist/`, + }, + plugins: [ + pluginQRCode(), + pluginVueLynx({ + optionsApi: false, + enableCSSSelector: true, + }), + // Generates src/generated/route-manifest.ts from src/pages/*/routes.json — + // the shared route table every page bundle embeds so isolated heaps agree + // on which page owns which route (see sparkling-history). + pluginRouteManifest({ + pagesDir, + outFile: path.join(__dirname, 'src/generated/route-manifest.ts'), + }), + ], +}) diff --git a/examples/vue-router-mpa/package.json b/examples/vue-router-mpa/package.json new file mode 100644 index 00000000..e116266f --- /dev/null +++ b/examples/vue-router-mpa/package.json @@ -0,0 +1,41 @@ +{ + "name": "@sparkling-example/vue-router-mpa", + "version": "0.0.0", + "private": false, + "type": "module", + "description": "Vue Router driving Sparkling multi-page native navigation (MPA) via sparkling-history.", + "homepage": "https://tiktok.github.io/sparkling/", + "files": [ + "dist", + "src", + "lynx.config.ts", + "tsconfig.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "examples/vue-router-mpa" + }, + "scripts": { + "build": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-history build && rspeedy build", + "dev": "rspeedy dev", + "preview": "rspeedy preview", + "dev:web-shell": "rspeedy build --environment web && LYNX_BUNDLE_DIR=\"$PWD/dist\" pnpm --filter sparkling-web-shell dev" + }, + "dependencies": { + "sparkling-history": "workspace:*", + "sparkling-method": "workspace:*", + "sparkling-navigation": "workspace:*", + "vue-lynx": "0.4.0", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@lynx-js/qrcode-rsbuild-plugin": "^0.4.4", + "@lynx-js/rspeedy": "^0.13.3", + "@rsbuild/plugin-vue": "^1.2.6", + "typescript": "^5.8.3" + }, + "engines": { + "node": "^22 || ^24" + } +} diff --git a/examples/vue-router-mpa/src/generated/route-manifest.ts b/examples/vue-router-mpa/src/generated/route-manifest.ts new file mode 100644 index 00000000..5244a535 --- /dev/null +++ b/examples/vue-router-mpa/src/generated/route-manifest.ts @@ -0,0 +1,33 @@ +// AUTO-GENERATED by sparkling-history/codegen — DO NOT EDIT. +import { defineRouteManifest } from 'sparkling-history' + +export default defineRouteManifest({ + "pages": [ + { + "bundle": "main", + "routes": [ + "/", + "/features" + ] + }, + { + "bundle": "settings", + "routes": [ + "/settings" + ], + "params": { + "title": "Settings" + } + }, + { + "bundle": "users", + "routes": [ + "/users", + { + "path": "/users/:id", + "name": "user-detail" + } + ] + } + ] +}) diff --git a/examples/vue-router-mpa/src/pages/main/App.vue b/examples/vue-router-mpa/src/pages/main/App.vue new file mode 100644 index 00000000..fa0aa31c --- /dev/null +++ b/examples/vue-router-mpa/src/pages/main/App.vue @@ -0,0 +1,25 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/main/index.ts b/examples/vue-router-mpa/src/pages/main/index.ts new file mode 100644 index 00000000..3aa52fd9 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/main/index.ts @@ -0,0 +1,25 @@ +// 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 { createApp } from 'vue-lynx' +import App from './App.vue' +import Home from './views/Home.vue' +import Features from './views/Features.vue' +import { createPageRouter } from '../../shared/router' + +const router = createPageRouter([ + { path: '/', name: 'home', component: Home }, + { path: '/features', name: 'features', component: Features }, +]) + +// Global guards run for LOCAL (in-container) navigations, like any SPA. +// Cross-page navigations don't run them here — the target page's own +// router runs its guards when its heap boots (MPA semantics). +router.beforeEach((to, from) => { + console.log(`[main] beforeEach: ${from.fullPath} -> ${to.fullPath}`) + return true +}) + +const app = createApp(App) +app.use(router) +app.mount() diff --git a/examples/vue-router-mpa/src/pages/main/routes.json b/examples/vue-router-mpa/src/pages/main/routes.json new file mode 100644 index 00000000..3eb918ef --- /dev/null +++ b/examples/vue-router-mpa/src/pages/main/routes.json @@ -0,0 +1,3 @@ +{ + "routes": ["/", "/features"] +} diff --git a/examples/vue-router-mpa/src/pages/main/views/Features.vue b/examples/vue-router-mpa/src/pages/main/views/Features.vue new file mode 100644 index 00000000..c2ccc1ff --- /dev/null +++ b/examples/vue-router-mpa/src/pages/main/views/Features.vue @@ -0,0 +1,20 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/main/views/Home.vue b/examples/vue-router-mpa/src/pages/main/views/Home.vue new file mode 100644 index 00000000..f7ee1b6d --- /dev/null +++ b/examples/vue-router-mpa/src/pages/main/views/Home.vue @@ -0,0 +1,60 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/settings/App.vue b/examples/vue-router-mpa/src/pages/settings/App.vue new file mode 100644 index 00000000..55c675a6 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/settings/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/settings/index.ts b/examples/vue-router-mpa/src/pages/settings/index.ts new file mode 100644 index 00000000..9c03e4d5 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/settings/index.ts @@ -0,0 +1,15 @@ +// 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 { createApp } from 'vue-lynx' +import App from './App.vue' +import Settings from './views/Settings.vue' +import { createPageRouter } from '../../shared/router' + +const router = createPageRouter([ + { path: '/settings', name: 'settings', component: Settings }, +]) + +const app = createApp(App) +app.use(router) +app.mount() diff --git a/examples/vue-router-mpa/src/pages/settings/routes.json b/examples/vue-router-mpa/src/pages/settings/routes.json new file mode 100644 index 00000000..8602c6a3 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/settings/routes.json @@ -0,0 +1,4 @@ +{ + "routes": ["/settings"], + "params": { "title": "Settings" } +} diff --git a/examples/vue-router-mpa/src/pages/settings/views/Settings.vue b/examples/vue-router-mpa/src/pages/settings/views/Settings.vue new file mode 100644 index 00000000..16e3b165 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/settings/views/Settings.vue @@ -0,0 +1,19 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/users/App.vue b/examples/vue-router-mpa/src/pages/users/App.vue new file mode 100644 index 00000000..75f433af --- /dev/null +++ b/examples/vue-router-mpa/src/pages/users/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/users/index.ts b/examples/vue-router-mpa/src/pages/users/index.ts new file mode 100644 index 00000000..35092d5b --- /dev/null +++ b/examples/vue-router-mpa/src/pages/users/index.ts @@ -0,0 +1,26 @@ +// 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 { createApp } from 'vue-lynx' +import App from './App.vue' +import UserList from './views/UserList.vue' +import UserDetail from './views/UserDetail.vue' +import { createPageRouter } from '../../shared/router' + +const router = createPageRouter([ + { path: '/users', name: 'users', component: UserList }, + // Dynamic segment + a per-route guard: both run entirely in this heap. + { + path: '/users/:id', + name: 'user-detail', + component: UserDetail, + beforeEnter: to => { + console.log(`[users] beforeEnter /users/${String(to.params.id)}`) + return true + }, + }, +]) + +const app = createApp(App) +app.use(router) +app.mount() diff --git a/examples/vue-router-mpa/src/pages/users/routes.json b/examples/vue-router-mpa/src/pages/users/routes.json new file mode 100644 index 00000000..7a5dd215 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/users/routes.json @@ -0,0 +1,6 @@ +{ + "routes": [ + "/users", + { "path": "/users/:id", "name": "user-detail" } + ] +} diff --git a/examples/vue-router-mpa/src/pages/users/views/UserDetail.vue b/examples/vue-router-mpa/src/pages/users/views/UserDetail.vue new file mode 100644 index 00000000..91840d65 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/users/views/UserDetail.vue @@ -0,0 +1,46 @@ + + + diff --git a/examples/vue-router-mpa/src/pages/users/views/UserList.vue b/examples/vue-router-mpa/src/pages/users/views/UserList.vue new file mode 100644 index 00000000..dc275bc3 --- /dev/null +++ b/examples/vue-router-mpa/src/pages/users/views/UserList.vue @@ -0,0 +1,35 @@ + + + diff --git a/examples/vue-router-mpa/src/shared/NavButton.vue b/examples/vue-router-mpa/src/shared/NavButton.vue new file mode 100644 index 00000000..c856efc5 --- /dev/null +++ b/examples/vue-router-mpa/src/shared/NavButton.vue @@ -0,0 +1,25 @@ + + + diff --git a/examples/vue-router-mpa/src/shared/router.ts b/examples/vue-router-mpa/src/shared/router.ts new file mode 100644 index 00000000..000a4e4e --- /dev/null +++ b/examples/vue-router-mpa/src/shared/router.ts @@ -0,0 +1,46 @@ +// 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 { RouteRecordRaw } from 'vue-router' +import { createSparklingRouter, type SparklingRouter } from 'sparkling-history/vue' +import { defaultLocationOf, findOwner } from 'sparkling-history' +import manifest from '../generated/route-manifest' + +/** + * Create the router for ONE page bundle (one Lynx container / JS heap). + * + * `routes` are the routes THIS page implements locally (in-container SPA + * navigation). Navigations resolving to routes owned by other pages — per + * the generated manifest — are diverted to Sparkling native navigation and + * open a new container. + */ +export function createPageRouter(routes: RouteRecordRaw[]): SparklingRouter { + const router = createSparklingRouter({ manifest, routes }) + const { hybridHistory } = router + + // Boot the initial navigation from the container's URL, like vue-router + // does on app install for browser histories. + // + // When the container URL carries no route this bundle owns — e.g. a + // standalone go-web web preview, where there is no scheme/queryItems — fall + // back to the bundle's own default route so each page previews correctly on + // its own instead of at a route another bundle owns. + const location = hybridHistory.location + const owner = findOwner(manifest, location.split('?')[0]) + const bootLocation = + owner && owner.page.bundle === hybridHistory.bundle + ? location + : defaultRouteFor(hybridHistory.bundle) + + router.push(bootLocation).catch(err => { + console.error('[vue-router-mpa] initial navigation failed:', err) + }) + + return router +} + +/** The default route of a page bundle, from the shared manifest. */ +function defaultRouteFor(bundle: string | null): string { + const page = manifest.pages.find(p => p.bundle === bundle) + return page ? defaultLocationOf(page) : '/' +} diff --git a/examples/vue-router-mpa/tsconfig.json b/examples/vue-router-mpa/tsconfig.json new file mode 100644 index 00000000..a2ffb718 --- /dev/null +++ b/examples/vue-router-mpa/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "jsx": "preserve", + "types": [] + }, + "include": ["src", "lynx.config.ts"] +} 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..53f31934 100644 --- a/packages/methods/sparkling-media/module.config.json +++ b/packages/methods/sparkling-media/module.config.json @@ -1,10 +1,37 @@ { "name": "sparkling-media", - "packageName": "com.tiktok.sparkling.method", - "moduleName": "Media", - "androidDsl": "kts", - "projectName": "sparkling-media", + "platforms": ["android", "ios", "web"], + "version": "1.0.0", + "description": "Media methods for choosing, uploading, and downloading media files in Sparkling apps", + "methods": { + "chooseMedia": { + "description": "Choose media from album or camera" + }, + "downloadFile": { + "description": "Download a file from server" + }, + "uploadFile": { + "description": "Upload a file to server" + }, + "uploadImage": { + "description": "Upload an image to server" + }, + "saveDataURL": { + "description": "Save base64 data URL to local file" + } + }, + "android": { + "packageName": "com.tiktok.sparkling.method.media", + "className": "MediaMethod", + "buildGradle": "android/build.gradle.kts" + }, "ios": { + "moduleName": "Media", + "className": "MediaMethod", "podspecPath": "ios/Sparkling-Media.podspec" + }, + "web": { + "entryPoint": "index.ts", + "bundleName": "media.bundle" } } diff --git a/packages/methods/sparkling-navigation/src/web/index.ts b/packages/methods/sparkling-navigation/src/web/index.ts index b3e8a2aa..d504f839 100644 --- a/packages/methods/sparkling-navigation/src/web/index.ts +++ b/packages/methods/sparkling-navigation/src/web/index.ts @@ -4,6 +4,24 @@ import { registerWebMethod } from 'sparkling-method/web-registry'; +/** Options forwarded from `router.open` to the {@link RouterWebHost}. */ +export interface RouterOpenOptions { + /** Replace the current entry/container instead of stacking a new one. */ + replace?: boolean; + /** + * Whether the host should animate the transition (a full-page host like + * `sparkling-web-shell` can slide the new container in). Mirrors the native + * `OpenOptions.animated`. Undefined lets the host pick its default. + */ + animated?: boolean; +} + +/** Options forwarded from `router.close` to the {@link RouterWebHost}. */ +export interface RouterCloseOptions { + /** Whether the host should animate the pop. Mirrors `CloseOptions.animated`. */ + animated?: boolean; +} + /** * How web navigation is actually performed. The default host drives the * browser History API and assumes the Lynx app owns the whole page (e.g. @@ -12,14 +30,15 @@ import { registerWebMethod } from 'sparkling-method/web-registry'; * so navigation stays scoped to the card instead of the top-level document. */ export interface RouterWebHost { - open(pageName: string, scheme: string): void; - close(): void; + open(pageName: string, scheme: string, options?: RouterOpenOptions): void; + close(options?: RouterCloseOptions): void; } const defaultHost: RouterWebHost = { - open(pageName, scheme) { + open(pageName, scheme, options) { const state = { page: pageName, scheme }; - window.history.pushState(state, '', `?page=${encodeURIComponent(pageName)}`); + const method = options?.replace ? 'replaceState' : 'pushState'; + window.history[method](state, '', `?page=${encodeURIComponent(pageName)}`); // Notify a full-page host (e.g. the web shell) to swap its . window.dispatchEvent( new CustomEvent('sparkling:navigate', { detail: { page: pageName, state } }), @@ -30,6 +49,12 @@ const defaultHost: RouterWebHost = { }, }; +/** Read an optional boolean field from the pipe `data` payload. */ +function readBool(data: unknown, key: string): boolean | undefined { + const value = (data as Record | null | undefined)?.[key]; + return typeof value === 'boolean' ? value : undefined; +} + let host: RouterWebHost = defaultHost; /** @@ -71,16 +96,17 @@ registerWebMethod('router.open', (params, callback) => { callback({ code: 0, msg: 'No bundle or url param in scheme' }); return; } - host.open(pageName, scheme); + const replace = (params.data as Record)?.replace === true; + host.open(pageName, scheme, { replace, animated: readBool(params.data, 'animated') }); callback({ code: 1, msg: 'ok' }); } catch (e) { callback({ code: 0, msg: `Failed to parse scheme: ${e}` }); } }); -registerWebMethod('router.close', (_params, callback) => { +registerWebMethod('router.close', (params, callback) => { try { - host.close(); + host.close({ animated: readBool(params.data, 'animated') }); callback({ code: 1, msg: 'ok' }); } catch (e) { callback({ code: 0, msg: `Failed to close: ${e}` }); diff --git a/packages/playground/package.json b/packages/playground/package.json index ccfda4e0..be68025c 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -13,6 +13,7 @@ "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 --config lynx.web.config.ts", "dev": "rspeedy dev", + "dev:web": "pnpm build:web && LYNX_BUNDLE_DIR=\"$PWD/dist-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..38e44c1a 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) { @@ -67,6 +71,7 @@ export async function devProject(options: DevOptions): Promise { if (isVerboseEnabled()) { verboseLog(`App config path: ${configPath}`); verboseLog(`Dev server port: ${port}`); + verboseLog(`Dev platform: ${platform}`); if (host) { verboseLog(`Dev server host: ${host}`); } @@ -76,7 +81,7 @@ export async function devProject(options: DevOptions): Promise { 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 +129,14 @@ export async function devProject(options: DevOptions): Promise { if (isVerboseEnabled()) { verboseLog(`Temp Lynx config: ${tempConfigPath}`); } - child = spawn('rspeedy', ['dev', '--config', tempConfigPath], { + const rspeedyArgs = ['dev', '--config', tempConfigPath]; + if (platform === 'web') { + rspeedyArgs.push('--environment', 'web'); + } else if (platform === 'native') { + rspeedyArgs.push('--environment', 'lynx'); + } + // platform === 'all' → no --environment flag → serves all environments + child = spawn('rspeedy', rspeedyArgs, { 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..2cada061 100644 --- a/packages/sparkling-app-cli/src/index.ts +++ b/packages/sparkling-app-cli/src/index.ts @@ -6,12 +6,15 @@ 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 { DEV_SERVER_PORT } from './constants'; +import { runWeb } from './commands/run-web'; import { ui } from './utils/ui'; import { enableVerboseLogging, isVerboseEnabled, verboseLog } from './utils/verbose'; import type { AppConfig } from './types'; @@ -41,16 +44,27 @@ 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 @@ -59,17 +73,24 @@ program .option('--config ', 'Path to app.config.ts', 'app.config.ts') .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)') + .option('--platform ', 'Target platform: web|native|all', 'all') .action(async opts => { const cwd = process.cwd(); const rawPort = opts.port === undefined ? undefined : Number.parseInt(String(opts.port), 10); 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-history/COMPATIBILITY.md b/packages/sparkling-history/COMPATIBILITY.md new file mode 100644 index 00000000..42a53bc6 --- /dev/null +++ b/packages/sparkling-history/COMPATIBILITY.md @@ -0,0 +1,120 @@ +# Vue Router × Sparkling MPA — Compatibility Matrix + +This is the acceptance deliverable: a full traversal of Vue Router's official +feature set (the [guide](https://router.vuejs.org/guide/) essentials + +advanced pages, and the repo's `e2e/` examples), answering for **each +feature** whether it can be supported when Vue Router drives **Sparkling +multi-page native navigation** — and, where it can't, exactly why. + +## How to read this + +The central fact of this integration is that navigation splits into two +regimes: + +- **In-heap (SPA)** — the target route is owned by the **current** page + bundle. This is a normal Vue Router transition inside one JS heap. The shim + does not touch it, so Vue Router behaves exactly as documented. +- **Cross-heap (MPA)** — the target route is owned by **another** bundle. The + shim diverts it to a native `router.open`, stacking a new container with its + own heap. Nothing but a URL crosses the boundary. + +A feature is "supported" when it works in the regime it belongs to. Several +features are **reframed** by MPA: they still work, but their mechanism moves +from Vue's runtime to the native platform (this is a feature, not a gap — it +is what "native navigation" means). + +Legend: ✅ supported & validated · 🟦 supported in-heap (Vue-native pass-through) +· 🔀 reframed by MPA (works, semantics move to native) · ⚠️ partial/conditional +· ❌ not supported (reason given) + +Validation column points at where the claim is exercised: +`history.spec` / `cross-container.spec` / `vue-adapter.spec` / +`vue-features.spec` are unit suites in `src/__tests__`; **e2e** is the +Playwright run against the web shell driving `examples/vue-router-mpa`. + +--- + +## Essentials + +| # | Feature | Regime | Status | Validation & notes | +|---|---------|--------|--------|--------------------| +| 1 | **Dynamic Route Matching** (`/users/:id`) | in-heap | ✅ | `vue-features.spec` (params), **e2e** (`params.id decoded`, sibling param nav). Cross-bundle dynamic targets resolve via the manifest and re-parse in the new heap. | +| 2 | **Routes' Matching Syntax** (custom regex, repeatable `+`/`*`, optional `?`) | in-heap | 🟦 | Runs in Vue Router's own matcher, unchanged. The *manifest* matcher (ownership only) supports static, `:param`, and trailing catch-all — enough to route to the owning bundle; the owning bundle's router then applies the full syntax. | +| 3 | **Named Routes** | both | ✅ | `vue-adapter.spec` (cross-bundle named), **e2e** (`named route opened other bundle`). Cross-bundle names resolve through the manifest (`findByName` + `fillPattern`) since the local matcher can't see them. | +| 4 | **Named / multiple ``** | in-heap | 🟦 | `vue-features.spec` (`components: { default, sidebar }`). A record's named views live in one heap; there is no cross-heap named view (each container is a separate document). | +| 5 | **Nested Routes** | in-heap | 🟦 | `vue-features.spec` (matched chain length 2). Nesting is intra-bundle; to nest *across* bundles you open a child container instead. | +| 6 | **Programmatic Navigation** (`push`/`replace`/`go`/`back`/`forward`) | both | ✅ | `history.spec`, `vue-adapter.spec`, **e2e**. `go(-n)` consumes the local queue first, then pops native containers (see §Limitations for forward). | +| 7 | **Redirect** (string / named / function) | in-heap | 🟦 | `vue-features.spec` (string + function redirect). A redirect whose target is another bundle is diverted to a native open like any push. | +| 8 | **Alias** | in-heap | 🟦 | `vue-features.spec` (`aliasOf` link). | +| 9 | **Passing Props to Route Components** (`props: true`/object/function) | in-heap | 🟦 | Pure Vue Router feature, untouched by the shim. Cross-heap "props" become URL query / history state instead. | +| 10 | **Active links** (`router-link-active`, exact-active) | in-heap | ⚠️ | Active-class logic works via `useLink`, but see #12 for `` rendering on Lynx. Active state is per-heap; a link can't be "active" for a route living in another container. | +| 11 | **History Modes** (`createWebHistory` / `WebHashHistory` / `MemoryHistory`) | — | 🔀 | **Replaced** by `createHybridHistory`. The three stock histories assume one heap + browser APIs; this integration supplies its own history. (Memory history remains the right choice for pure in-`LynxView` SPA — the VueLynx model.) | +| 12 | **``** default rendering | in-heap | ⚠️ | `` renders `h('a', { href, onClick })`; **Lynx has no `` element**. Use `` (bind `@tap="navigate"`) or `useLink`, exactly as VueLynx examples do. This is a Lynx platform constraint, independent of MPA. The demo uses programmatic navigation + a `NavButton`. | + +## Advanced + +| # | Feature | Regime | Status | Validation & notes | +|---|---------|--------|--------|--------------------| +| 13 | **Navigation Guards** — global `beforeEach`/`beforeResolve`/`afterEach` | in-heap | ✅ | `vue-features.spec` (abort via `false`, redirect, `afterEach`), **e2e** (`[main] beforeEach` logs). | +| 14 | Guards — per-route `beforeEnter` | in-heap | ✅ | `users` bundle declares `beforeEnter` on `/users/:id`; runs on in-heap entry. | +| 15 | Guards — in-component (`onBeforeRouteLeave`/`Update`) | in-heap | 🟦 | Standard Vue Router; runs for in-heap transitions. **Cross-heap caveat:** leaving a page by opening another container does *not* run the opener's leave guards — the opener stays mounted underneath (it wasn't left). This matches browser MPA: navigating to a new document doesn't fire the old document's in-app leave guards. | +| 16 | **Route Meta Fields** (`meta`) | in-heap | 🟦 | Untouched Vue Router feature. Meta does not cross heaps (not serialized into the scheme); put cross-page data in query/state. | +| 17 | **Data Fetching** (guard-driven / after-nav) | in-heap | 🟦 | Works per-heap. Each opened container fetches on its own boot — the natural MPA data-loading point. | +| 18 | **Composition API** (`useRoute`/`useRouter`/`useLink`) | in-heap | ✅ | Used throughout `examples/vue-router-mpa`; `useRouter()` returns the `SparklingRouter` (adds `hybridHistory`). | +| 19 | **RouterView slot** (``) | in-heap | 🟦 | Pure Vue feature; unaffected. | +| 20 | **Transitions** (`` + ``) | in-heap | ⚠️ | In-heap route transitions work (Vue ``). **Cross-container** transitions are the *native* push/pop animation (owned by Sparkling / the platform), not Vue transitions — you get platform animation, configurable via scheme params (e.g. `animated`). | +| 21 | **Scroll Behavior** (`scrollBehavior`, saved position) | — | ❌ | Not supported. Vue Router's `scrollBehavior` drives `window.scrollTo` / `document` scroll and reads `window.history.state.scroll`; **Lynx has no window scroll** (scrolling lives inside ``/``, per-container). Saved-position restore across containers is likewise a native concern. Platform limitation, not fixable in the shim. | +| 22 | **Extending RouterLink** (`useLink`, custom link components) | in-heap | 🟦 | `useLink` works; `href` is a Sparkling scheme (from `createHref`). Build custom link components with `custom` slot. | +| 23 | **Navigation Failures** (`NavigationFailure`, `isNavigationFailure`) | both | ✅ | In-heap: `vue-features.spec` (aborted push returns a failure). Cross-heap: `push`/`replace` return a promise that resolves when the native open succeeds (or rejects if no bundle owns the target / the host `open` fails). | +| 24 | **Dynamic Routing** (`addRoute`/`removeRoute`/`hasRoute`) | in-heap | ⚠️ | `vue-features.spec` (add + remove). Works within a heap at runtime. **Cross-bundle** routes cannot be added at runtime: the manifest that connects heaps is build-time (a new heap wouldn't know about a route another heap added in memory). Regenerate the manifest + rebuild to add cross-bundle routes. | +| 25 | **Typed Routes** (typed `RouteMap`) | — | 🟦 | Type-level only; compatible. The manifest could feed a typed-routes generator (future work). | +| 26 | **Lazy Loading** (`() => import()`) | both | 🔀 | In-heap async components work as usual. **Cross-bundle is itself lazy loading**: each page bundle is a separate artifact the host loads on demand at `router.open` — route-level code-splitting is the native container boundary. | + +## `e2e/` examples + +| Example | Status | Notes | +|---------|--------|-------| +| `encoding` | ✅ | Param/URL encoding round-trips through the codec — `cross-container.spec` (`round-trips locations losslessly`, encoded param), **e2e** deeplink with encoded query. | +| `guards-instances` | 🟦 | In-heap guard/instance behavior is Vue Router's own; unaffected. | +| `hash` | 🔀 | Hash history is replaced by the scheme codec; the "hash" role (opaque location token) is played by `__hs_route`. | +| `keep-alive` | 🔀/🟦 | In-heap `` works. Across containers, the *native stack itself* keeps backgrounded pages alive — a stacked page's heap is preserved and revealed intact on close (validated by **e2e** `restored ×N` + `main container revealed with state intact`). Stronger than ``: full heap preservation, not a component cache. | +| `modal` | ⚠️ | The "modal route over a background route" pattern works in-heap. A modal that is a *separate container* is a native presentation (e.g. a non-fullscreen scheme), not a Vue overlay. | +| `multi-app` | 🟦 | Each container already hosts its own Vue app instance/router — the multi-app model is the default here (one app per heap). | +| `scroll-behavior` | ❌ | See #21. | +| `suspense` | 🟦 | `` is a Vue feature, independent of history; works per-heap. | +| `transitions` | ⚠️ | See #20. | + +--- + +## MPA limitations, stated plainly + +These are inherent to "multiple heaps connected only by URLs", not +shortcomings of the shim: + +1. **No forward across a closed container.** `history.go(+n)` past the local + queue cannot re-enter a container that was popped — its heap is gone. Back + works (it pops live containers); forward beyond the queue is clamped with a + warning. In a browser MPA, forward after a real navigation reloads a fresh + document; the native stack has no such entry to return to. +2. **Only URL-serializable data crosses the boundary.** Route, JSON state, and + depth travel in the scheme. No shared reactive stores, no live object refs, + no `meta` — by design. Richer hand-off goes through a native channel + (`sparkling-storage`, a custom method). +3. **Cross-bundle route table is build-time.** Heaps agree via the generated + manifest; you can't teach one heap about another's runtime `addRoute`. +4. **Guards are per-heap.** The opener's leave/afterEach guards don't run when + you open another container (it stays mounted underneath); the opened page + runs its own guards on boot. This mirrors browser MPA document semantics. +5. **Scroll restoration is native, not Vue.** (#21.) + +## Verdict + +Everything Vue Router does **inside a page** is supported unchanged — the shim +returns a real `Router` and only intercepts cross-bundle `push`/`replace`. +Everything Vue Router does **between pages** is mapped onto Sparkling native +navigation, with route/state/depth carried in the scheme and a build-time +manifest connecting the heaps. The only hard *no* is **scroll behavior** +(no window scroll on Lynx); `` needs the `custom` slot (no `` +on Lynx); and a handful of features are **reframed** (history modes, lazy +loading, keep-alive, transitions) because their mechanism legitimately moves +from Vue's runtime to the native platform in an MPA. diff --git a/packages/sparkling-history/README.md b/packages/sparkling-history/README.md new file mode 100644 index 00000000..83d27d23 --- /dev/null +++ b/packages/sparkling-history/README.md @@ -0,0 +1,119 @@ +# sparkling-history + +A framework-agnostic **history shim** that lets SPA routers (Vue Router +first) drive **Sparkling's multi-page native navigation** — the MPA model — +where each page is a separate Lynx container in its own JS heap. + +> This is deliberately different from [VueLynx](https://vue.lynxjs.org)'s +> in-`LynxView` SPA routing, which uses `createMemoryHistory` to keep all +> routes inside a single heap. Here the router drives navigation *between* +> native containers that do **not** share memory. The only thing they can +> share is a build-time **route manifest**, so that is what connects them. + +## The layering (why it's reusable) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Router framework vue-router (or any router that │ +│ accepts a RouterHistory-like obj) │ +├──────────────────────────────────────────────────────────────┤ +│ Adapter sparkling-history/vue │ ← per-framework, thin +│ createSparklingRouter() │ +├──────────────────────────────────────────────────────────────┤ +│ Shim core (this package) HybridRouterHistory │ ← framework-agnostic +│ SchemeCodec + RouteManifest │ +├──────────────────────────────────────────────────────────────┤ +│ Host contract NavigationHost │ ← platform-agnostic +│ open() / close() / initialUrl / visibility │ +├──────────────────────────────────────────────────────────────┤ +│ Platform Sparkling native │ Web shell │ Memory │ +│ (sparkling-nav) │ (harness) │ (tests) │ +└──────────────────────────────────────────────────────────────┘ +``` + +Each seam is a small, explicit contract: + +- **`NavigationHost`** — the platform floor. Anything that can *open* and + *close* stacked pages addressed by a URL implements it. Ships with a + Sparkling binding (`sparkling-history/sparkling`, over + `sparkling-navigation`) and an in-memory simulator (for tests / SSR). A + bespoke native shell only needs to satisfy this interface to reuse the + whole stack above. +- **`SchemeCodec` + `RouteManifest`** — the cross-heap contract. The manifest + is generated from the file-based page convention at build time and embedded + in every bundle, so isolated heaps agree on which page owns which route + without sharing memory. The codec maps router locations ⇆ sparkling scheme + URLs and carries `route`/`state`/`depth` across the boundary in the URL. +- **`HybridRouterHistory`** — structurally compatible with vue-router's + `RouterHistory`, so it drops into `createRouter({ history })`. In-container + it behaves like memory history; at the container boundary it delegates to + the host. + +Because the router only ever sees a `RouterHistory`, the same core works for +any router that accepts a custom history, and the same host works for any +framework — the point of keeping the layers apart. + +## How a navigation flows + +`router.push('/detail/42')` inside the `home` page bundle: + +1. the vue adapter resolves `/detail/42` and asks the codec **who owns it** +2. owned by *another* bundle (`detail`) → the adapter calls + `history.pushExternal('/detail/42')` instead of a local transition +3. the codec encodes it to + `hybrid://lynxview_page?bundle=detail.lynx.bundle&__hs_route=%2Fdetail%2F42&__hs_depth=1` +4. `NavigationHost.open(scheme)` → `sparkling-navigation`'s `router.open` + stacks a new native container (a fresh JS heap) +5. the `detail` bundle boots, builds its own `HybridRouterHistory`, decodes + its container URL, and starts its router at `/detail/42` — running *its* + guards, exactly like a browser MPA loading a new document + +If `/detail/42` had been owned by the *same* bundle, step 2 stays local and +it is an ordinary in-heap vue-router transition (nested views, guards, params +— all untouched). + +## Usage + +```ts +// one router per page bundle (one heap) +import { createSparklingRouter } from 'sparkling-history/vue' +import manifest from './generated/route-manifest' // built by the codegen plugin + +const router = createSparklingRouter({ + manifest, + routes: [ /* only the routes THIS bundle implements locally */ ], +}) +router.push(router.hybridHistory.location) // initial navigation +``` + +Build-time manifest generation (rsbuild/rspeedy): + +```ts +import { pluginRouteManifest } from 'sparkling-history/codegen' + +plugins: [ + pluginRouteManifest({ + pagesDir: 'src/pages', + outFile: 'src/generated/route-manifest.ts', + }), +] +``` + +See [`examples/vue-router-mpa`](../../examples/vue-router-mpa) for a full working demo, +and [`COMPATIBILITY.md`](./COMPATIBILITY.md) for the Vue Router feature matrix. + +## What crosses the heap boundary + +Only what fits in a URL: the target **route**, the navigation **state** +(JSON-serialized), and the stack **depth**. This is the fundamental MPA +constraint — there is no shared memory, no shared reactive store, no live +object references between pages. Anything richer must go through a native +channel (`sparkling-storage`, a native module, etc.). + +## Testing + +`createMemoryNavigationEnvironment()` simulates the native container stack in +one process, so cross-heap flows are exercised with **real vue-router +instances** — one per simulated container — without a device. The suite ports +vue-router's official memory-history tests (in-container behavior) and adds +cross-container + adapter coverage. Run `pnpm --filter sparkling-history test`. diff --git a/packages/sparkling-history/package.json b/packages/sparkling-history/package.json new file mode 100644 index 00000000..ecd9fc6e --- /dev/null +++ b/packages/sparkling-history/package.json @@ -0,0 +1,66 @@ +{ + "name": "sparkling-history", + "version": "0.1.0", + "description": "W3C-History-style navigation shim that lets SPA routers (e.g. vue-router) drive MPA-style native navigation on Sparkling / Lynx", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-history" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./sparkling": { + "types": "./dist/hosts/sparkling.d.ts", + "default": "./dist/hosts/sparkling.js" + }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "default": "./dist/vue/index.js" + }, + "./codegen": { + "types": "./dist/codegen/index.d.ts", + "default": "./dist/codegen/index.js" + } + }, + "typesVersions": { + "*": { + "sparkling": ["dist/hosts/sparkling.d.ts"], + "vue": ["dist/vue/index.d.ts"], + "codegen": ["dist/codegen/index.d.ts"] + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "sparkling-navigation": "workspace:*" + }, + "peerDependencies": { + "vue-router": ">=4.4.0" + }, + "peerDependenciesMeta": { + "vue-router": { + "optional": true + } + }, + "devDependencies": { + "@lynx-js/types": "3.7.0", + "@types/node": "^22.15.3", + "typescript": "^5.8.3", + "vitest": "^3.1.2", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-history/src/__tests__/cross-container.spec.ts b/packages/sparkling-history/src/__tests__/cross-container.spec.ts new file mode 100644 index 00000000..7cabd6c7 --- /dev/null +++ b/packages/sparkling-history/src/__tests__/cross-container.spec.ts @@ -0,0 +1,189 @@ +// 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. + +/** + * Cross-container (MPA) behavior: each container is a separate JS heap in + * production. The memory environment simulates the native stack; a fresh + * HybridHistory is created per container from nothing but its URL — the + * same constraint real heaps have. + */ + +import { describe, expect, it, vi } from 'vitest' +import { createHybridHistory } from '../history' +import { createSparklingSchemeCodec } from '../codec' +import { defineRouteManifest } from '../manifest' +import { + createMemoryNavigationEnvironment, + type SimulatedContainer, +} from '../hosts/memory' + +const manifest = defineRouteManifest({ + pages: [ + { bundle: 'main', routes: ['/'] }, + { bundle: 'detail', routes: [{ path: '/detail/:id', name: 'detail' }] }, + { bundle: 'settings', routes: ['/settings', '/settings/about'], params: { hide_nav_bar: true } }, + ], +}) +const codec = createSparklingSchemeCodec(manifest) + +function historyFor(container: SimulatedContainer) { + return createHybridHistory({ host: container.host, codec }) +} + +describe('cross-container navigation', () => { + it('pushExternal stacks a new container without touching local history', async () => { + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + const history = historyFor(root) + + await history.pushExternal('/detail/42') + + // native stack grew... + expect(env.stack).toHaveLength(2) + // ...but the opener's local history did not move (MPA semantics) + expect(history.location).toBe('/') + + // the new heap reconstructs its history purely from its URL + const detailHistory = historyFor(env.top!) + expect(detailHistory.location).toBe('/detail/42') + expect(detailHistory.depth).toBe(1) + expect(detailHistory.bundle).toBe('detail') + }) + + it('passes history state across heaps through the URL', async () => { + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + const history = historyFor(root) + + await history.pushExternal('/detail/1', { from: 'home', count: 2 }) + + const detailHistory = historyFor(env.top!) + expect(detailHistory.state).toEqual({ from: 'home', count: 2 }) + }) + + it('preserves the initial cross-heap state through the router first-nav replace', async () => { + // vue-router's first navigation is `replace(fullPath, { scroll })`, which + // must NOT wipe the state decoded from the container URL. This mirrors the + // browser HTML5 history where replaceState merges the existing state. + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + await historyFor(root).pushExternal('/detail/1', { greeting: 'hi' }) + + const detailHistory = historyFor(env.top!) + // simulate the router install replacing the current location on boot + detailHistory.replace(detailHistory.location, { scroll: false }) + expect(detailHistory.state).toEqual({ greeting: 'hi', scroll: false }) + }) + + it('pushExternal with replace swaps the current container', async () => { + const env = createMemoryNavigationEnvironment() + env.open(codec.encode('/', { depth: 0 })!) + const second = env.open(codec.encode('/settings', { depth: 1 })!) + const history = historyFor(second) + + await history.pushExternal('/detail/9', undefined, { replace: true }) + + expect(env.stack).toHaveLength(2) + const replacedHistory = historyFor(env.top!) + expect(replacedHistory.location).toBe('/detail/9') + // replace keeps the depth of the replaced container + expect(replacedHistory.depth).toBe(1) + }) + + it('rejects pushExternal for routes no page owns', async () => { + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + const history = historyFor(root) + + await expect(history.pushExternal('/nowhere')).rejects.toThrow( + /no page in the route manifest owns/ + ) + expect(env.stack).toHaveLength(1) + }) + + it('go(-n) crossing the boundary pops native containers', async () => { + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + await historyFor(root).pushExternal('/settings') + const settings = env.top! + const settingsHistory = historyFor(settings) + + // two local entries + the container itself + settingsHistory.push('/settings/about') + expect(settingsHistory.location).toBe('/settings/about') + + // -1 consumed locally, remaining -1 pops the container + settingsHistory.go(-2) + expect(env.stack).toHaveLength(1) + expect(env.top!).toBe(root) + }) + + it('restore fires when a covering container closes (pageshow-like)', async () => { + const env = createMemoryNavigationEnvironment() + const root = env.open(codec.encode('/', { depth: 0 })!) + const rootHistory = historyFor(root) + + const restoreSpy = vi.fn() + rootHistory.onRestore(restoreSpy) + + await rootHistory.pushExternal('/detail/1') + expect(restoreSpy).toHaveBeenCalledWith({ visible: false }) + + const detailHistory = historyFor(env.top!) + detailHistory.go(-1) // closes the detail container + + expect(restoreSpy).toHaveBeenLastCalledWith({ visible: true }) + // the revealed container's own location never changed — no popstate, + // matching browser MPA semantics (bfcache restore fires pageshow) + expect(rootHistory.location).toBe('/') + }) +}) + +describe('scheme codec', () => { + it('encodes owned routes with bundle + route + depth', () => { + const url = codec.encode('/detail/42?tab=posts#bio', { depth: 3 })! + expect(url).toContain('bundle=detail.lynx.bundle') + expect(url).toContain('__hs_depth=3') + expect(url).toContain(encodeURIComponent('/detail/42?tab=posts#bio')) + }) + + it('appends per-page container params from the manifest', () => { + const url = codec.encode('/settings')! + expect(url).toContain('hide_nav_bar=true') + }) + + it('returns null for unowned routes', () => { + expect(codec.encode('/nope')).toBeNull() + expect(codec.ownerOf('/nope')).toBeNull() + }) + + it('round-trips locations losslessly', () => { + const location = '/detail/hello%20world?q=a%26b#frag' + const decoded = codec.decode(codec.encode(location, { depth: 1 })!) + expect(decoded?.location).toBe(location) + expect(decoded?.depth).toBe(1) + }) + + it('decodes plain deeplinks to the page default route with extras', () => { + const decoded = codec.decode( + 'hybrid://lynxview_page?bundle=settings.lynx.bundle&ref=push&title=Settings' + ) + // `title` is a reserved container param; `ref` forwards to the route + expect(decoded?.location).toBe('/settings?ref=push') + expect(decoded?.bundle).toBe('settings') + }) + + it('decodes dev-server url= deeplinks', () => { + const decoded = codec.decode( + 'hybrid://lynxview_page?url=http%3A%2F%2Flocalhost%3A5969%2Fdetail.lynx.bundle' + ) + expect(decoded?.bundle).toBe('detail') + // dynamic-only page has no static default: falls back to '/' + expect(decoded?.location).toBe('/') + }) + + it('returns null for undecodable urls', () => { + expect(codec.decode('hybrid://lynxview_page?foo=1')).toBeNull() + }) +}) diff --git a/packages/sparkling-history/src/__tests__/history.spec.ts b/packages/sparkling-history/src/__tests__/history.spec.ts new file mode 100644 index 00000000..52c526a3 --- /dev/null +++ b/packages/sparkling-history/src/__tests__/history.spec.ts @@ -0,0 +1,265 @@ +// 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. + +/** + * In-container history behavior. + * + * Ported from vue-router's official memory-history suite + * (vuejs/router `packages/router/__tests__/history/memory.spec.ts`) — + * within a container, HybridHistory must behave exactly like vue-router's + * `createMemoryHistory`. Cases that cross the container boundary (where the + * two histories intentionally diverge) are covered in `cross-container.spec.ts`. + */ + +import { describe, expect, it, vi } from 'vitest' +import { createHybridHistory } from '../history' +import { createSparklingSchemeCodec, ROUTE_PARAM } from '../codec' +import { defineRouteManifest } from '../manifest' +import { createMemoryNavigationEnvironment } from '../hosts/memory' +import type { HistoryLocation, HybridRouterHistory } from '../types' + +const loc: HistoryLocation = '/foo' +const loc2: HistoryLocation = '/bar' + +const manifest = defineRouteManifest({ + pages: [ + { bundle: 'main', routes: ['/', '/foo', '/bar', '/somewhere', '/other', '/1', '/2', '/3', '/4', '/5'] }, + ], +}) +const codec = createSparklingSchemeCodec(manifest) + +/** START equivalent: a hybrid history starts at its container's route. */ +const START: HistoryLocation = '/' + +function createHistory(base?: string): HybridRouterHistory { + const env = createMemoryNavigationEnvironment() + const container = env.open(codec.encode(START, { depth: 0 })!) + return createHybridHistory({ host: container.host, codec, base }) +} + +describe('Hybrid history (in-container, ported from memory history)', () => { + it('starts at the container route', () => { + const history = createHistory() + expect(history.location).toEqual(START) + }) + + it('can push a location', () => { + const history = createHistory() + history.push('/somewhere?foo=foo#hey') + expect(history.location).toEqual('/somewhere?foo=foo#hey') + }) + + it('can replace a location', () => { + const history = createHistory() + history.replace('/somewhere?foo=foo#hey') + expect(history.location).toEqual('/somewhere?foo=foo#hey') + }) + + it('does not trigger listeners with push', () => { + const history = createHistory() + const spy = vi.fn() + history.listen(spy) + history.push(loc) + expect(spy).not.toHaveBeenCalled() + }) + + it('does not trigger listeners with replace', () => { + const history = createHistory() + const spy = vi.fn() + history.listen(spy) + history.replace(loc) + expect(spy).not.toHaveBeenCalled() + }) + + it('can go back', () => { + const history = createHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + expect(history.location).toEqual(loc) + history.go(-1) + expect(history.location).toEqual(START) + }) + + it('stores a state', () => { + const history = createHistory() + history.push(loc, { foo: 'bar' }) + expect(history.state).toEqual({ foo: 'bar' }) + history.push(loc, { foo: 'baz' }) + expect(history.state).toEqual({ foo: 'baz' }) + history.go(-1) + expect(history.state).toEqual({ foo: 'bar' }) + }) + + it('does nothing with forward if at end of log', () => { + const history = createHistory() + history.go(1) + expect(history.location).toEqual(START) + }) + + it('can moves back and forth in history queue', () => { + const history = createHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + history.go(-1) + expect(history.location).toEqual(START) + history.go(1) + expect(history.location).toEqual(loc) + history.go(1) + expect(history.location).toEqual(loc2) + }) + + it('can push in the middle of the history', () => { + const history = createHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + history.go(-1) + expect(history.location).toEqual(START) + history.push(loc2) + expect(history.location).toEqual(loc2) + // does nothing + history.go(1) + expect(history.location).toEqual(loc2) + }) + + it('can listen to navigations', () => { + const history = createHistory() + const spy = vi.fn() + history.listen(spy) + history.push(loc) + history.go(-1) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(START, loc, { + direction: 'back', + delta: -1, + type: 'pop', + }) + history.go(1) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenLastCalledWith(loc, START, { + direction: 'forward', + delta: 1, + type: 'pop', + }) + }) + + it('can stop listening to navigation', () => { + const history = createHistory() + const spy = vi.fn() + const spy2 = vi.fn() + // remove right away + history.listen(spy)() + const remove = history.listen(spy2) + history.push(loc) + history.go(-1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + remove() + history.go(1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + }) + + it('removing the same listener is a noop', () => { + const history = createHistory() + const spy = vi.fn() + const spy2 = vi.fn() + const rem = history.listen(spy) + const rem2 = history.listen(spy2) + rem() + rem() + history.push(loc) + history.go(-1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + rem2() + rem2() + history.go(1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + }) + + it('removes all listeners with destroy', () => { + const history = createHistory() + history.push('/other') + const spy = vi.fn() + history.listen(spy) + history.destroy() + history.push('/2') + history.go(-1) + expect(spy).not.toHaveBeenCalled() + }) + + it('can be reused after destroy', () => { + const history = createHistory() + history.push('/1') + history.push('/2') + history.push('/3') + history.go(-1) + + expect(history.location).toBe('/2') + history.destroy() + history.go(-1) + expect(history.location).toBe(START) + history.push('/4') + history.push('/5') + expect(history.location).toBe('/5') + history.go(-1) + expect(history.location).toBe('/4') + }) + + it('can avoid listeners with back and forward', () => { + const history = createHistory() + const spy = vi.fn() + history.listen(spy) + history.push(loc) + history.go(-1, false) + expect(spy).not.toHaveBeenCalled() + history.go(1, false) + expect(spy).not.toHaveBeenCalled() + }) + + it('handles a non-empty base', () => { + expect(createHistory('/foo/').base).toBe('/foo') + expect(createHistory('/foo').base).toBe('/foo') + }) + + // ── Intentional divergences from memory history ────────────────────── + // In vue-router's memory history, going back past the start of the queue + // clamps. A hybrid container instead pops the NATIVE stack: back on the + // first entry closes the container, like the system back button. + + it('going back past the queue start closes the container', () => { + const env = createMemoryNavigationEnvironment() + const container = env.open(codec.encode(START, { depth: 0 })!) + const history = createHybridHistory({ host: container.host, codec }) + expect(env.stack).toHaveLength(1) + history.go(-1) + expect(env.stack).toHaveLength(0) + }) + + it('restores initial state from the initial url', () => { + const env = createMemoryNavigationEnvironment() + const url = codec.encode('/foo', { depth: 2, state: { fromPrev: 1 } })! + const container = env.open(url) + const history = createHybridHistory({ host: container.host, codec }) + expect(history.location).toBe('/foo') + expect(history.state).toEqual({ fromPrev: 1 }) + expect(history.depth).toBe(2) + }) + + it('createHref produces a scheme for owned routes', () => { + const history = createHistory() + const href = history.createHref('/foo') + expect(href).toContain('bundle=main.lynx.bundle') + expect(href).toContain(`${ROUTE_PARAM}=${encodeURIComponent('/foo')}`) + }) + + it('createHref falls back to base + path for unowned routes', () => { + const history = createHistory('/base') + expect(history.createHref('/not-in-manifest')).toBe('/base/not-in-manifest') + }) +}) diff --git a/packages/sparkling-history/src/__tests__/vue-adapter.spec.ts b/packages/sparkling-history/src/__tests__/vue-adapter.spec.ts new file mode 100644 index 00000000..5c8e57b2 --- /dev/null +++ b/packages/sparkling-history/src/__tests__/vue-adapter.spec.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. + +/** + * Real vue-router instances driving the shim: one Router per simulated + * container, exactly how one Router lives in each Lynx heap in production. + */ + +import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { createSparklingRouter } from '../vue' +import { createHybridHistory } from '../history' +import { createSparklingSchemeCodec } from '../codec' +import { defineRouteManifest } from '../manifest' +import { + createMemoryNavigationEnvironment, + type MemoryNavigationEnvironment, + type SimulatedContainer, +} from '../hosts/memory' + +const Page = defineComponent({ template: '
' }) + +const manifest = defineRouteManifest({ + pages: [ + { bundle: 'main', routes: ['/', '/main/nested'] }, + { bundle: 'detail', routes: [{ path: '/detail/:id', name: 'detail' }] }, + { bundle: 'settings', routes: ['/settings'] }, + ], +}) +const codec = createSparklingSchemeCodec(manifest) + +/** Boot a router inside a container, like a page bundle would on startup. */ +async function bootRouter( + container: SimulatedContainer, + routes: Parameters[0]['routes'] +) { + const history = createHybridHistory({ host: container.host, codec }) + const router = createSparklingRouter({ manifest, history, routes }) + await router.push(history.location) // initial navigation (install does this) + return router +} + +const mainRoutes = [ + { path: '/', component: Page }, + { path: '/main/nested', component: Page }, +] +const detailRoutes = [{ path: '/detail/:id', name: 'detail', component: Page }] + +function openRoot(env: MemoryNavigationEnvironment) { + return env.open(codec.encode('/', { depth: 0 })!) +} + +describe('createSparklingRouter', () => { + it('keeps same-bundle navigation local (SPA within the container)', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + await router.push('/main/nested') + + expect(router.currentRoute.value.fullPath).toBe('/main/nested') + expect(env.stack).toHaveLength(1) // no native navigation happened + }) + + it('runs guards for local navigations', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + const guard = vi.fn(() => true) + router.beforeEach(guard) + await router.push('/main/nested') + + expect(guard).toHaveBeenCalledTimes(1) + }) + + it('diverts cross-bundle push to a native open', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + await router.push('/detail/42') + + expect(env.stack).toHaveLength(2) + // the opener's route did NOT change: the page stays alive underneath + expect(router.currentRoute.value.fullPath).toBe('/') + }) + + it('does not run local guards for cross-bundle navigations (MPA semantics)', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + const guard = vi.fn(() => true) + router.beforeEach(guard) + await router.push('/detail/42') + + expect(guard).not.toHaveBeenCalled() + }) + + it('boots the target page router at the pushed route, running ITS guards', async () => { + const env = createMemoryNavigationEnvironment() + const mainRouter = await bootRouter(openRoot(env), mainRoutes) + await mainRouter.push('/detail/42?tab=posts') + + // ── new heap ── + const detailContainer = env.top! + const detailHistory = createHybridHistory({ + host: detailContainer.host, + codec, + }) + const detailRouter = createSparklingRouter({ + manifest, + history: detailHistory, + routes: detailRoutes, + }) + const guard = vi.fn(() => true) + detailRouter.beforeEach(guard) + await detailRouter.push(detailHistory.location) + + expect(detailRouter.currentRoute.value.fullPath).toBe('/detail/42?tab=posts') + expect(detailRouter.currentRoute.value.params.id).toBe('42') + expect(guard).toHaveBeenCalledTimes(1) + }) + + it('supports cross-bundle named routes via the manifest', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + await router.push({ name: 'detail', params: { id: '7' }, query: { ref: 'home' } }) + + expect(env.stack).toHaveLength(2) + const detailHistory = createHybridHistory({ host: env.top!.host, codec }) + expect(detailHistory.location).toBe('/detail/7?ref=home') + }) + + it('replace() swaps the native container for cross-bundle targets', async () => { + const env = createMemoryNavigationEnvironment() + openRoot(env) + const settings = env.open(codec.encode('/settings', { depth: 1 })!) + const router = await bootRouter(settings, [ + { path: '/settings', component: Page }, + ]) + + await router.replace('/detail/1') + + expect(env.stack).toHaveLength(2) + const top = createHybridHistory({ host: env.top!.host, codec }) + expect(top.location).toBe('/detail/1') + expect(top.depth).toBe(1) + }) + + it('passes navigation state to the next heap', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + await router.push({ path: '/detail/1', state: { fromList: true } }) + + const detailHistory = createHybridHistory({ host: env.top!.host, codec }) + expect(detailHistory.state).toEqual({ fromList: true }) + }) + + it('router.back() beyond local history closes the container', async () => { + const env = createMemoryNavigationEnvironment() + openRoot(env) + const detail = env.open(codec.encode('/detail/5', { depth: 1 })!) + const router = await bootRouter(detail, detailRoutes) + + router.back() + + expect(env.stack).toHaveLength(1) + }) + + it('exposes the hybrid history for restore hooks', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + const restore = vi.fn() + router.hybridHistory.onRestore(restore) + + await router.push('/detail/1') + expect(restore).toHaveBeenCalledWith({ visible: false }) + + const detailHistory = createHybridHistory({ host: env.top!.host, codec }) + detailHistory.go(-1) + expect(restore).toHaveBeenLastCalledWith({ visible: true }) + }) + + it('rejects unknown named routes with vue-router error (not a native open)', async () => { + const env = createMemoryNavigationEnvironment() + const router = await bootRouter(openRoot(env), mainRoutes) + + // vue-router throws synchronously for unresolvable names + expect(() => router.push({ name: 'does-not-exist' })).toThrow(/No match/) + expect(env.stack).toHaveLength(1) + }) +}) diff --git a/packages/sparkling-history/src/__tests__/vue-features.spec.ts b/packages/sparkling-history/src/__tests__/vue-features.spec.ts new file mode 100644 index 00000000..0e906b7d --- /dev/null +++ b/packages/sparkling-history/src/__tests__/vue-features.spec.ts @@ -0,0 +1,161 @@ +// 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. + +/** + * Evidence that createSparklingRouter leaves vue-router's IN-HEAP feature set + * intact. The adapter only wraps push/replace to divert CROSS-bundle targets; + * everything that resolves within the current bundle must behave exactly like + * a stock vue-router. These cases mirror vue-router's own guide features + * (dynamic matching, nested routes, named views, redirect, alias, guards, + * dynamic routing) and back the compatibility matrix in the package README. + */ + +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import { createSparklingRouter } from '../vue' +import { createHybridHistory } from '../history' +import { createSparklingSchemeCodec } from '../codec' +import { defineRouteManifest } from '../manifest' +import { createMemoryNavigationEnvironment } from '../hosts/memory' + +const Stub = defineComponent({ render: () => h('div') }) + +// One bundle "app" owns every route here so nothing diverts to native — we are +// testing the in-heap pass-through. A second bundle exists only to prove the +// interception boundary still fires. +const manifest = defineRouteManifest({ + pages: [ + { bundle: 'app', routes: ['/', '/users/:id', '/parent', '/dashboard', '/home', '/root-alias'] }, + { bundle: 'other', routes: ['/other'] }, + ], +}) +const codec = createSparklingSchemeCodec(manifest) + +function bootRouter(routes: RouteRecordRaw[]) { + const env = createMemoryNavigationEnvironment() + const container = env.open(codec.encode('/', { depth: 0 })!) + const history = createHybridHistory({ host: container.host, codec }) + const router = createSparklingRouter({ manifest, history, routes }) + return { router, env } +} + +describe('vue-router in-heap features pass through the adapter', () => { + it('dynamic route matching resolves params', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/users/:id', component: Stub }, + ]) + await router.push('/users/42') + expect(router.currentRoute.value.params.id).toBe('42') + }) + + it('nested routes build the matched chain', async () => { + const { router } = bootRouter([ + { + path: '/parent', + component: Stub, + children: [{ path: 'child', component: Stub }], + }, + ]) + await router.push('/parent/child') + expect(router.currentRoute.value.matched).toHaveLength(2) + expect(router.currentRoute.value.fullPath).toBe('/parent/child') + }) + + it('named views resolve multiple components for one record', async () => { + const { router } = bootRouter([ + { + path: '/dashboard', + components: { default: Stub, sidebar: Stub }, + }, + ]) + await router.push('/dashboard') + const record = router.currentRoute.value.matched[0] + expect(Object.keys(record.components ?? {}).sort()).toEqual([ + 'default', + 'sidebar', + ]) + }) + + it('redirect (string) follows through', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/home', redirect: '/' }, + ]) + await router.push('/home') + expect(router.currentRoute.value.path).toBe('/') + }) + + it('redirect (function) can rewrite to a query', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/users/:id', component: Stub }, + { path: '/parent', redirect: () => ({ path: '/users/7' }) }, + ]) + await router.push('/parent') + expect(router.currentRoute.value.path).toBe('/users/7') + }) + + it('alias matches the aliased path', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub, alias: '/root-alias' }, + ]) + await router.push('/root-alias') + const record = router.currentRoute.value.matched[0] + // the alias resolves and links back to the original '/' record + expect(record.aliasOf?.path).toBe('/') + expect(record.components?.default).toBe(Stub) + }) + + it('a global guard returning false aborts the navigation', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/users/:id', component: Stub }, + ]) + router.beforeEach(to => (to.path === '/users/1' ? false : true)) + const failure = await router.push('/users/1') + expect(failure).toBeTruthy() // NavigationFailure + expect(router.currentRoute.value.path).toBe('/') + }) + + it('a guard can redirect', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/dashboard', component: Stub }, + { path: '/home', component: Stub }, + ]) + router.beforeEach(to => (to.path === '/home' ? '/dashboard' : true)) + await router.push('/home') + expect(router.currentRoute.value.path).toBe('/dashboard') + }) + + it('afterEach runs for in-heap navigations', async () => { + const { router } = bootRouter([ + { path: '/', component: Stub }, + { path: '/users/:id', component: Stub }, + ]) + const spy = vi.fn() + router.afterEach(spy) + await router.push('/users/9') + expect(spy).toHaveBeenCalled() + }) + + it('dynamic routing via addRoute works in-heap', async () => { + const { router } = bootRouter([{ path: '/', component: Stub }]) + router.addRoute({ path: '/users/:id', name: 'u', component: Stub }) + await router.push('/users/5') + expect(router.currentRoute.value.name).toBe('u') + router.removeRoute('u') + expect(router.hasRoute('u')).toBe(false) + }) + + it('the interception boundary still diverts cross-bundle targets', async () => { + const { router, env } = bootRouter([{ path: '/', component: Stub }]) + await router.push('/other') + // did NOT resolve locally; opened a native container instead + expect(router.currentRoute.value.path).toBe('/') + expect(env.stack).toHaveLength(2) + }) +}) diff --git a/packages/sparkling-history/src/codec.ts b/packages/sparkling-history/src/codec.ts new file mode 100644 index 00000000..d06bd093 --- /dev/null +++ b/packages/sparkling-history/src/codec.ts @@ -0,0 +1,203 @@ +// 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 { + DecodedLocation, + HistoryLocation, + HistoryState, + SchemeCodec, +} from './types' +import { + defaultLocationOf, + findOwner, + type RouteManifest, +} from './manifest' + +export const DEFAULT_SCHEME = 'hybrid://lynxview_page' + +/** Reserved scheme params carrying router coordinates across heaps. */ +export const ROUTE_PARAM = '__hs_route' +export const STATE_PARAM = '__hs_state' +export const DEPTH_PARAM = '__hs_depth' + +/** + * Container/loader params that must never leak into a decoded route's query. + * (Sparkling container params like `title` stay out of the route location.) + */ +const RESERVED_PARAMS = new Set([ + ROUTE_PARAM, + STATE_PARAM, + DEPTH_PARAM, + 'bundle', + 'url', + 'containerInitTime', + 'fullscreen', + 'title', + 'title_color', + 'hide_nav_bar', + 'nav_bar_color', + 'screen_orientation', + 'hide_status_bar', + 'trans_status_bar', + 'show_nav_bar_in_trans_status_bar', + 'hide_loading', + 'loading_bg_color', + 'container_bg_color', + 'hide_error', + 'force_theme_style', + 'status_font_mode', + 'hide_back_button', +]) + +function pathOf(location: HistoryLocation): string { + const stop = Math.min( + ...['?', '#'] + .map(c => location.indexOf(c)) + .filter(i => i >= 0) + .concat(location.length) + ) + return location.slice(0, stop) +} + +/** Tiny query parser: no URL/URLSearchParams dependency, `%20`-style only. */ +export function parseQuery(url: string): Record { + const result: Record = {} + const qIndex = url.indexOf('?') + if (qIndex < 0) return result + const hashIndex = url.indexOf('#', qIndex) + const query = url.slice(qIndex + 1, hashIndex < 0 ? undefined : hashIndex) + for (const pair of query.split('&')) { + if (!pair) continue + const eq = pair.indexOf('=') + const key = eq < 0 ? pair : pair.slice(0, eq) + const value = eq < 0 ? '' : pair.slice(eq + 1) + try { + result[decodeURIComponent(key)] = decodeURIComponent(value) + } catch { + result[key] = value + } + } + return result +} + +export interface SparklingCodecOptions { + /** + * Suffix appended to the bundle name in generated schemes. + * Default `.lynx.bundle` (rspeedy's `[name].lynx.bundle` convention). + */ + bundleSuffix?: string +} + +/** + * Codec between router locations and sparkling schemes. + * + * Encoding: `/detail/42?tab=a` (owned by page `detail`) becomes + * `hybrid://lynxview_page?bundle=detail.lynx.bundle&__hs_route=%2Fdetail%2F42%3Ftab%3Da&__hs_depth=1` + * + * The full router location rides along in `__hs_route`, so the target heap + * recovers it losslessly without reverse-templating path params. `bundle=` + * tells the native/web container loader what to load — in dev mode + * sparkling-navigation rewrites it to `url=` targeting the dev server. + * + * Decoding accepts both freshly-encoded schemes and plain deeplinks + * (`?bundle=detail.lynx.bundle&id=42` → the page's default route with + * non-reserved params appended as query). + */ +export function createSparklingSchemeCodec( + manifest: RouteManifest, + options: SparklingCodecOptions = {} +): SchemeCodec { + const scheme = (manifest.scheme ?? DEFAULT_SCHEME).replace(/[?&]+$/, '') + const bundleSuffix = options.bundleSuffix ?? '.lynx.bundle' + + function ownerOf(to: HistoryLocation): string | null { + const owner = findOwner(manifest, pathOf(to)) + return owner ? owner.page.bundle : null + } + + function encode( + to: HistoryLocation, + context: { state?: HistoryState; depth?: number } = {} + ): string | null { + const owner = findOwner(manifest, pathOf(to)) + if (!owner) return null + + const parts: string[] = [ + `bundle=${encodeURIComponent(owner.page.bundle + bundleSuffix)}`, + `${ROUTE_PARAM}=${encodeURIComponent(to)}`, + `${DEPTH_PARAM}=${context.depth ?? 0}`, + ] + if (context.state !== undefined) { + parts.push(`${STATE_PARAM}=${encodeURIComponent(JSON.stringify(context.state))}`) + } + for (const [key, value] of Object.entries(owner.page.params ?? {})) { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + } + return `${scheme}?${parts.join('&')}` + } + + function decode(url: string): DecodedLocation | null { + const query = parseQuery(url) + + const depth = Number.parseInt(query[DEPTH_PARAM] ?? '0', 10) || 0 + + let state: HistoryState | undefined + if (query[STATE_PARAM]) { + try { + state = JSON.parse(query[STATE_PARAM]) as HistoryState + } catch { + state = undefined + } + } + + const bundleName = bundleNameFrom(query, bundleSuffix) + + if (query[ROUTE_PARAM]) { + return { + location: query[ROUTE_PARAM], + state, + depth, + bundle: bundleName ?? undefined, + } + } + + // Plain deeplink: derive the page from bundle= / url= and use its + // default route, forwarding non-reserved params as query. + if (!bundleName) return null + const page = manifest.pages.find(p => p.bundle === bundleName) + if (!page) return null + + const extras = Object.entries(query) + .filter(([key]) => !RESERVED_PARAMS.has(key)) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + + let location = defaultLocationOf(page) + if (extras.length) { + location += (location.includes('?') ? '&' : '?') + extras.join('&') + } + return { location, state, depth, bundle: bundleName } + } + + return { encode, decode, ownerOf } +} + +function bundleNameFrom( + query: Record, + bundleSuffix: string +): string | null { + const strip = (name: string) => { + const base = name.split('/').filter(Boolean).pop() ?? '' + return base.endsWith(bundleSuffix) + ? base.slice(0, -bundleSuffix.length) + : base + } + if (query.bundle) return strip(query.bundle) || null + if (query.url) { + // dev server URL, e.g. http://localhost:5969/detail.lynx.bundle + const noProto = query.url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') + const path = noProto.slice(noProto.indexOf('/') + 1).split(/[?#]/)[0] ?? '' + return strip(path) || null + } + return null +} diff --git a/packages/sparkling-history/src/codegen/index.ts b/packages/sparkling-history/src/codegen/index.ts new file mode 100644 index 00000000..b4f4757a --- /dev/null +++ b/packages/sparkling-history/src/codegen/index.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. + +/** + * Build-time route-manifest generation (Node-only). + * + * Sparkling pages live in different JS heaps, so the route table connecting + * them must be established AHEAD of time and embedded into every bundle. + * This module derives that table from the file-based page convention: + * + * src/pages//index.* → page bundle "" + * src/pages//routes.json → routes owned by the page (optional) + * + * Without `routes.json`, a page owns a single conventional route: + * `/` for root pages (`main`/`index`/`home` by default), `/` otherwise. + * + * `routes.json` shape (all fields optional except `routes`): + * ```json + * { + * "routes": ["/detail/:id", { "path": "/detail/:id/edit", "name": "edit" }], + * "default": "/detail/1", + * "params": { "hide_nav_bar": true } + * } + * ``` + */ + +import fs from 'node:fs' +import path from 'node:path' +import type { + RouteManifest, + RouteManifestPage, + RouteManifestRoute, +} from '../manifest' + +export interface ScanPagesOptions { + /** Directory containing one sub-directory per page (e.g. `src/pages`). */ + pagesDir: string + /** Base scheme recorded in the manifest. */ + scheme?: string + /** Per-page route config file name. Default `routes.json`. */ + configFile?: string + /** Page names whose conventional route is `/`. Default main/index/home. */ + rootPages?: string[] +} + +interface PageRoutesConfig { + routes?: RouteManifestRoute[] + default?: string + params?: Record +} + +export function scanPages(options: ScanPagesOptions): RouteManifest { + const configFile = options.configFile ?? 'routes.json' + const rootPages = options.rootPages ?? ['main', 'index', 'home'] + + if (!fs.existsSync(options.pagesDir)) { + throw new Error( + `[sparkling-history] pagesDir does not exist: ${options.pagesDir}` + ) + } + + const pages: RouteManifestPage[] = [] + const entries = fs + .readdirSync(options.pagesDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + + for (const entry of entries) { + const pageDir = path.join(options.pagesDir, entry.name) + const configPath = path.join(pageDir, configFile) + + let config: PageRoutesConfig = {} + if (fs.existsSync(configPath)) { + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as + | PageRoutesConfig + | RouteManifestRoute[] + config = Array.isArray(raw) ? { routes: raw } : raw + } + + const conventionalRoute = rootPages.includes(entry.name) + ? '/' + : `/${entry.name}` + + pages.push({ + bundle: entry.name, + routes: config.routes?.length ? config.routes : [conventionalRoute], + ...(config.default ? { default: config.default } : {}), + ...(config.params ? { params: config.params } : {}), + }) + } + + return { + ...(options.scheme ? { scheme: options.scheme } : {}), + pages, + } +} + +/** Render a manifest as an importable TypeScript module. */ +export function renderManifestModule(manifest: RouteManifest): string { + return [ + '// AUTO-GENERATED by sparkling-history/codegen — DO NOT EDIT.', + "import { defineRouteManifest } from 'sparkling-history'", + '', + `export default defineRouteManifest(${JSON.stringify(manifest, null, 2)})`, + '', + ].join('\n') +} + +export interface GenerateRouteManifestOptions extends ScanPagesOptions { + /** Path of the generated module (e.g. `src/generated/route-manifest.ts`). */ + outFile: string +} + +/** + * Scan pages and write the manifest module. Skips the write when content is + * unchanged so watch-mode builds don't loop. + */ +export function generateRouteManifest( + options: GenerateRouteManifestOptions +): RouteManifest { + const manifest = scanPages(options) + const content = renderManifestModule(manifest) + const current = fs.existsSync(options.outFile) + ? fs.readFileSync(options.outFile, 'utf8') + : null + if (current !== content) { + fs.mkdirSync(path.dirname(options.outFile), { recursive: true }) + fs.writeFileSync(options.outFile, content) + } + return manifest +} + +/** + * Minimal rsbuild/rspeedy plugin: regenerates the manifest before builds and + * dev-server startup, so `src/pages/**` stays the single source of truth. + */ +export function pluginRouteManifest(options: GenerateRouteManifestOptions): { + name: string + setup(api: { + onBeforeBuild?: (fn: () => void) => void + onBeforeStartDevServer?: (fn: () => void) => void + }): void +} { + return { + name: 'sparkling-history:route-manifest', + setup(api) { + const regenerate = () => { + generateRouteManifest(options) + } + regenerate() + api.onBeforeBuild?.(regenerate) + api.onBeforeStartDevServer?.(regenerate) + }, + } +} diff --git a/packages/sparkling-history/src/history.ts b/packages/sparkling-history/src/history.ts new file mode 100644 index 00000000..9f39f6ca --- /dev/null +++ b/packages/sparkling-history/src/history.ts @@ -0,0 +1,204 @@ +// 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 { + HistoryLocation, + HistoryState, + HybridRouterHistory, + NavigationCallback, + NavigationDirection, + NavigationHost, + NavigationInformation, + SchemeCodec, +} from './types' + +export interface HybridHistoryOptions { + host: NavigationHost + codec: SchemeCodec + /** + * Base prepended to hrefs of locations no page owns. Kept for + * RouterHistory compatibility; sparkling schemes ignore it. Default `''`. + */ + base?: string + /** Fallback location when the initial URL cannot be decoded. Default `/`. */ + fallbackLocation?: HistoryLocation +} + +/** + * A history for one container (one JS heap) of an MPA-style hybrid app. + * + * Within the container it behaves like vue-router's memory history — pushes + * and replaces mutate a local queue, `go()` walks it and notifies listeners + * (the `popstate` equivalent). At the container boundary it delegates to the + * {@link NavigationHost}: + * + * - `pushExternal()` opens ANOTHER container via the host (native push) + * - `go(-n)` beyond the local queue pops native containers via `host.close` + * - the initial location/state/depth are decoded from `host.initialUrl`, + * which is how a fresh heap joins a navigation session it shares with + * other heaps only through URLs. + */ +export function createHybridHistory( + options: HybridHistoryOptions +): HybridRouterHistory { + const { host, codec } = options + // normalize like vue-router: no trailing slash, so `base + fullPath` works + const base = (options.base ?? '').replace(/\/+$/, '') + + const decoded = codec.decode(host.initialUrl) + const initialLocation = + decoded?.location ?? options.fallbackLocation ?? '/' + const depth = decoded?.depth ?? 0 + const bundle = decoded?.bundle ?? codec.ownerOf(initialLocation) + + let listeners: NavigationCallback[] = [] + let restoreListeners: Array<(info: { visible: boolean }) => void> = [] + let queue: Array<[url: HistoryLocation, state: HistoryState]> = [ + [initialLocation, decoded?.state ?? {}], + ] + let position = 0 + let teardownVisibility: (() => void) | undefined + + function setLocation(location: HistoryLocation, state: HistoryState = {}) { + position++ + if (position !== queue.length) { + // we are in the middle: drop the forward part of the queue + queue.splice(position) + } + queue.push([location, state]) + } + + function triggerListeners( + to: HistoryLocation, + from: HistoryLocation, + { direction, delta }: Pick + ): void { + const info: NavigationInformation = { direction, delta, type: 'pop' } + for (const callback of listeners.slice()) { + callback(to, from, info) + } + } + + function ensureVisibilitySubscription() { + if (teardownVisibility || !host.onVisibilityChange) return + teardownVisibility = host.onVisibilityChange(visible => { + for (const callback of restoreListeners.slice()) { + callback({ visible }) + } + }) + } + + const routerHistory: HybridRouterHistory = { + // rewritten by Object.defineProperty below + location: initialLocation, + // rewritten by Object.defineProperty below + state: {}, + base, + depth, + bundle, + + createHref(to) { + return codec.encode(to, { depth: depth + 1 }) ?? base + to + }, + + push(to, state?: HistoryState) { + setLocation(to, state) + }, + + replace(to, data?: HistoryState) { + // Mirror vue-router's HTML5 history: replaceState merges the existing + // entry's state with the new data (`assign({}, history.state, data)`). + // This is what preserves the initial cross-heap state through the + // router's first navigation, which is a `replace(fullPath, { scroll })`. + const merged: HistoryState = { ...queue[position][1], ...data } + queue.splice(position--, 1) + setLocation(to, merged) + }, + + go(delta, shouldTrigger = true) { + const from = this.location + const target = position + delta + + if (target < 0) { + // Crossing the container boundary backwards: pop native containers. + // Each container counts as ONE entry regardless of its local queue. + // No listeners fire — this container is about to be dismissed, the + // revealed container gets a restore (pageshow-like) event instead. + position = 0 + const count = -target + void host.close({ count }).catch(err => { + console.error('[sparkling-history] host.close failed:', err) + }) + return + } + + if (target > queue.length - 1) { + console.warn( + `[sparkling-history] go(${delta}) exceeds the local history queue. ` + + 'Forward navigation cannot re-enter closed containers (MPA limitation); clamping.' + ) + } + + const direction: NavigationDirection = delta < 0 ? 'back' : 'forward' + position = Math.max(0, Math.min(target, queue.length - 1)) + if (shouldTrigger) { + triggerListeners(this.location, from, { direction, delta }) + } + }, + + pushExternal(to, data?: HistoryState, opts?: { replace?: boolean }) { + const replace = opts?.replace ?? false + const url = codec.encode(to, { + state: data, + depth: replace ? depth : depth + 1, + }) + if (!url) { + return Promise.reject( + new Error( + `[sparkling-history] no page in the route manifest owns "${to}"` + ) + ) + } + return host.open(url, { replace }) + }, + + listen(callback) { + listeners.push(callback) + return () => { + const index = listeners.indexOf(callback) + if (index > -1) listeners.splice(index, 1) + } + }, + + onRestore(callback) { + ensureVisibilitySubscription() + restoreListeners.push(callback) + return () => { + const index = restoreListeners.indexOf(callback) + if (index > -1) restoreListeners.splice(index, 1) + } + }, + + destroy() { + listeners = [] + restoreListeners = [] + teardownVisibility?.() + teardownVisibility = undefined + queue = [[initialLocation, {}]] + position = 0 + }, + } + + Object.defineProperty(routerHistory, 'location', { + enumerable: true, + get: () => queue[position][0], + }) + + Object.defineProperty(routerHistory, 'state', { + enumerable: true, + get: () => queue[position][1], + }) + + return routerHistory +} diff --git a/packages/sparkling-history/src/hosts/memory.ts b/packages/sparkling-history/src/hosts/memory.ts new file mode 100644 index 00000000..11cbe0ba --- /dev/null +++ b/packages/sparkling-history/src/hosts/memory.ts @@ -0,0 +1,152 @@ +// 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 { + NavigationHost, + NavigationHostCloseOptions, + NavigationHostOpenOptions, +} from '../types' + +/** + * In-memory simulation of a native container stack. + * + * Each simulated container gets its own {@link NavigationHost}, so tests can + * instantiate one `HybridRouterHistory` (and one router) per container and + * exercise real cross-"heap" flows — open, replace, close, visibility + * restore — without a device. Also usable for SSR-ish environments. + */ + +export interface SimulatedContainer { + readonly id: string + readonly url: string + readonly host: NavigationHost + /** Whether this container is the visible top of the stack. */ + readonly visible: boolean +} + +export interface MemoryNavigationEnvironment { + /** Bottom → top. */ + readonly stack: readonly SimulatedContainer[] + readonly top: SimulatedContainer | undefined + /** Open a container from outside (e.g. simulate the app's entry deeplink). */ + open(url: string, options?: NavigationHostOpenOptions): SimulatedContainer + /** Notified after every stack mutation. */ + onStackChange(callback: () => void): () => void +} + +interface ContainerImpl { + id: string + url: string + visible: boolean + visibilityListeners: Array<(visible: boolean) => void> + host: NavigationHost +} + +export function createMemoryNavigationEnvironment(): MemoryNavigationEnvironment { + const stack: ContainerImpl[] = [] + let nextId = 0 + let stackListeners: Array<() => void> = [] + + function notifyStackChange() { + for (const listener of stackListeners.slice()) listener() + } + + function setVisible(container: ContainerImpl, visible: boolean) { + if (container.visible === visible) return + container.visible = visible + for (const listener of container.visibilityListeners.slice()) { + listener(visible) + } + } + + function syncVisibility() { + stack.forEach((container, index) => { + setVisible(container, index === stack.length - 1) + }) + } + + function createContainer(url: string): ContainerImpl { + const id = `container-${nextId++}` + const container: ContainerImpl = { + id, + url, + visible: false, + visibilityListeners: [], + host: { + initialUrl: url, + containerId: id, + open(childUrl, options) { + openFrom(container, childUrl, options) + return Promise.resolve() + }, + close(options) { + closeFrom(container, options) + return Promise.resolve() + }, + onVisibilityChange(callback) { + container.visibilityListeners.push(callback) + return () => { + const index = container.visibilityListeners.indexOf(callback) + if (index > -1) container.visibilityListeners.splice(index, 1) + } + }, + }, + } + return container + } + + function openFrom( + opener: ContainerImpl | null, + url: string, + options?: NavigationHostOpenOptions + ): ContainerImpl { + const container = createContainer(url) + if (options?.replace && opener) { + const index = stack.indexOf(opener) + if (index > -1) { + stack.splice(index, 1, container) + } else { + stack.push(container) + } + } else { + stack.push(container) + } + syncVisibility() + notifyStackChange() + return container + } + + function closeFrom( + container: ContainerImpl, + options?: NavigationHostCloseOptions + ) { + const count = Math.max(1, options?.count ?? 1) + const index = stack.indexOf(container) + if (index < 0) return + // close self and (count - 1) containers below, clamped at the stack root + const start = Math.max(0, index - count + 1) + stack.splice(start, index - start + 1) + syncVisibility() + notifyStackChange() + } + + return { + get stack() { + return stack.slice() + }, + get top() { + return stack[stack.length - 1] + }, + open(url, options) { + return openFrom(null, url, options) + }, + onStackChange(callback) { + stackListeners.push(callback) + return () => { + const index = stackListeners.indexOf(callback) + if (index > -1) stackListeners.splice(index, 1) + } + }, + } +} diff --git a/packages/sparkling-history/src/hosts/sparkling.ts b/packages/sparkling-history/src/hosts/sparkling.ts new file mode 100644 index 00000000..b52f333a --- /dev/null +++ b/packages/sparkling-history/src/hosts/sparkling.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 { open as sparklingOpen, close as sparklingClose } from 'sparkling-navigation' +import type { NavigationHost } from '../types' +import { DEFAULT_SCHEME } from '../codec' + +/** + * {@link NavigationHost} implementation over the Sparkling container stack. + * + * Runs inside a Lynx background thread (one per container/heap): + * - the initial URL is rebuilt from `lynx.__globalProps.queryItems`, the + * query params the Sparkling SDK parsed off this container's scheme + * - `open`/`close` map to sparkling-navigation's `router.open`/`router.close` + * - visibility maps to the `viewAppeared`/`viewDisappeared` GlobalEvents + * the Sparkling SDK sends to every container. + */ + +interface GlobalEventEmitterLike { + addListener(event: string, listener: (...args: unknown[]) => void): void + removeListener(event: string, listener: (...args: unknown[]) => void): void +} + +interface LynxGlobalLike { + __globalProps?: { + containerID?: string + queryItems?: Record + } + getJSModule?(name: string): unknown +} + +declare const lynx: LynxGlobalLike | undefined + +function getLynx(): LynxGlobalLike | undefined { + return typeof lynx !== 'undefined' ? lynx : undefined +} + +export interface SparklingHostOptions { + /** Base scheme used to rebuild this container's initial URL. */ + scheme?: string +} + +export function createSparklingNavigationHost( + options: SparklingHostOptions = {} +): NavigationHost { + const scheme = options.scheme ?? DEFAULT_SCHEME + const globalProps = getLynx()?.__globalProps + + const queryItems = globalProps?.queryItems ?? {} + const query = Object.entries(queryItems) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + + return { + initialUrl: query ? `${scheme}?${query}` : scheme, + containerId: globalProps?.containerID ?? '', + + open(url, opts) { + return new Promise((resolve, reject) => { + sparklingOpen( + { + scheme: url, + options: opts?.replace ? { replace: true } : undefined, + }, + result => { + if (result.code === 1) resolve() + else reject(new Error(`[sparkling-history] router.open failed: ${result.msg}`)) + } + ) + }) + }, + + close(opts) { + const count = opts?.count ?? 1 + if (count > 1) { + // Sparkling's router.close only dismisses the current container. + // Multi-entry pops (history.go(-n) across containers) degrade to a + // single pop — see the compatibility notes in the README. + console.warn( + `[sparkling-history] close({ count: ${count} }) is not supported by ` + + 'sparkling-navigation; closing only the current container.' + ) + } + return new Promise((resolve, reject) => { + sparklingClose({}, result => { + if (result.code === 1) resolve() + else reject(new Error(`[sparkling-history] router.close failed: ${result.msg}`)) + }) + }) + }, + + onVisibilityChange(callback) { + const emitter = getLynx()?.getJSModule?.('GlobalEventEmitter') as + | GlobalEventEmitterLike + | undefined + if (!emitter?.addListener) { + return () => {} + } + const onAppear = () => callback(true) + const onDisappear = () => callback(false) + emitter.addListener('viewAppeared', onAppear) + emitter.addListener('viewDisappeared', onDisappear) + return () => { + emitter.removeListener('viewAppeared', onAppear) + emitter.removeListener('viewDisappeared', onDisappear) + } + }, + } +} diff --git a/packages/sparkling-history/src/index.ts b/packages/sparkling-history/src/index.ts new file mode 100644 index 00000000..8429d6cf --- /dev/null +++ b/packages/sparkling-history/src/index.ts @@ -0,0 +1,34 @@ +// 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 * from './types' +export { createHybridHistory, type HybridHistoryOptions } from './history' +export { + createSparklingSchemeCodec, + parseQuery, + DEFAULT_SCHEME, + ROUTE_PARAM, + STATE_PARAM, + DEPTH_PARAM, + type SparklingCodecOptions, +} from './codec' +export { + defineRouteManifest, + matchPattern, + findOwner, + findByName, + fillPattern, + normalizeRoute, + defaultLocationOf, + type RouteManifest, + type RouteManifestPage, + type RouteManifestRoute, + type OwnerLookup, + type PatternMatch, +} from './manifest' +export { + createMemoryNavigationEnvironment, + type MemoryNavigationEnvironment, + type SimulatedContainer, +} from './hosts/memory' diff --git a/packages/sparkling-history/src/manifest.ts b/packages/sparkling-history/src/manifest.ts new file mode 100644 index 00000000..adafdc7b --- /dev/null +++ b/packages/sparkling-history/src/manifest.ts @@ -0,0 +1,188 @@ +// 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 route manifest is the piece of build-time metadata that connects + * pages living in DIFFERENT JS heaps. Each Sparkling page bundle embeds the + * same manifest (generated from the file-based page convention, see + * `sparkling-history/codegen`), so every heap can independently answer: + * + * "which page bundle owns route X?" → cross-page navigations become + * `router.open(scheme)` calls instead of in-memory route changes. + */ + +/** A route pattern entry: a bare pattern or a pattern with a route name. */ +export type RouteManifestRoute = string | { path: string; name?: string } + +export interface RouteManifestPage { + /** Page bundle name (rspeedy entry name), e.g. `"detail"`. */ + bundle: string + /** + * Route path patterns owned by this page. Supports vue-router-style + * `:param` segments and a trailing `*` / `:param*` catch-all. The FIRST + * pattern is the page's primary route. Entries may carry a route `name` + * so other pages can navigate cross-page by name. + */ + routes: RouteManifestRoute[] + /** + * Concrete location to use when the container was opened without an + * explicit route (e.g. a plain deeplink `?bundle=detail.lynx.bundle`). + * Defaults to the first static-only pattern, else `/`. + */ + default?: string + /** + * Extra container params appended to generated schemes for this page + * (e.g. `{ hide_nav_bar: true, title: 'Detail' }`). + */ + params?: Record +} + +export interface RouteManifest { + /** Base scheme for generated URLs. Default: `hybrid://lynxview_page`. */ + scheme?: string + pages: RouteManifestPage[] +} + +/** Identity helper for typed manifest modules. */ +export function defineRouteManifest(manifest: RouteManifest): RouteManifest { + return manifest +} + +// ── Path pattern matching ────────────────────────────────────────────── + +export interface PatternMatch { + /** Number of static segments matched (used to rank specificity). */ + staticScore: number + params: Record +} + +/** + * Match a concrete path (`/users/1`) against a pattern (`/users/:id`). + * Returns null when it doesn't match. + * + * Deliberately tiny: full matching (regex params, optional params, + * sensitivity...) happens inside each page's own router. The manifest only + * needs enough to decide OWNERSHIP of a path. + */ +export function matchPattern( + pattern: string, + path: string +): PatternMatch | null { + const patternSegs = split(pattern) + const pathSegs = split(path) + const params: Record = {} + let staticScore = 0 + + let i = 0 + for (; i < patternSegs.length; i++) { + const pat = patternSegs[i] + const isCatchAll = pat === '*' || (pat.startsWith(':') && pat.endsWith('*')) + if (isCatchAll) { + const name = pat === '*' ? 'pathMatch' : pat.slice(1, -1) + params[name] = pathSegs.slice(i).join('/') + return { staticScore, params } + } + if (i >= pathSegs.length) return null + if (pat.startsWith(':')) { + params[pat.slice(1)] = pathSegs[i] + } else if (pat === pathSegs[i]) { + staticScore++ + } else { + return null + } + } + return i === pathSegs.length ? { staticScore, params } : null +} + +function split(path: string): string[] { + return path.replace(/^\/+|\/+$/g, '') === '' + ? [] + : path.replace(/^\/+|\/+$/g, '').split('/') +} + +export function normalizeRoute(route: RouteManifestRoute): { + path: string + name?: string +} { + return typeof route === 'string' ? { path: route } : route +} + +export interface OwnerLookup { + page: RouteManifestPage + pattern: string + match: PatternMatch +} + +/** Find the page owning `path`, ranked by static-segment specificity. */ +export function findOwner( + manifest: RouteManifest, + path: string +): OwnerLookup | null { + let best: OwnerLookup | null = null + for (const page of manifest.pages) { + for (const route of page.routes) { + const { path: pattern } = normalizeRoute(route) + const match = matchPattern(pattern, path) + if (match) { + if (!best || match.staticScore > best.match.staticScore) { + best = { page, pattern, match } + } + } + } + } + return best +} + +/** Find a named route across all pages of the manifest. */ +export function findByName( + manifest: RouteManifest, + name: string +): { page: RouteManifestPage; pattern: string } | null { + for (const page of manifest.pages) { + for (const route of page.routes) { + const normalized = normalizeRoute(route) + if (normalized.name === name) { + return { page, pattern: normalized.path } + } + } + } + return null +} + +/** + * Fill a pattern's params to produce a concrete path: + * `fillPattern('/users/:id', { id: '1' })` → `/users/1`. + */ +export function fillPattern( + pattern: string, + params: Record> = {} +): string { + const segments = pattern.split('/').map(segment => { + if (!segment.startsWith(':') && segment !== '*') return segment + const isCatchAll = segment === '*' || segment.endsWith('*') + const name = + segment === '*' + ? 'pathMatch' + : segment.slice(1, isCatchAll ? -1 : undefined) + const value = params[name] + if (value === undefined || value === null) { + throw new Error( + `[sparkling-history] missing param "${name}" to fill pattern "${pattern}"` + ) + } + const values = Array.isArray(value) ? value : [value] + return values.map(v => encodeURIComponent(String(v))).join('/') + }) + const path = segments.join('/') + return path.startsWith('/') ? path : '/' + path +} + +/** Resolve the concrete default location for a page. */ +export function defaultLocationOf(page: RouteManifestPage): string { + if (page.default) return page.default + const firstStatic = page.routes + .map(normalizeRoute) + .find(r => !r.path.includes(':') && !r.path.includes('*')) + return firstStatic?.path ?? '/' +} diff --git a/packages/sparkling-history/src/types.ts b/packages/sparkling-history/src/types.ts new file mode 100644 index 00000000..ecb1b860 --- /dev/null +++ b/packages/sparkling-history/src/types.ts @@ -0,0 +1,191 @@ +// 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. + +/** + * Core contracts of the sparkling-history shim. + * + * The types in this file intentionally mirror the shape of vue-router's + * `RouterHistory` interface (vue-router/src/history/common.ts) so a + * {@link HybridRouterHistory} can be passed straight to vue-router's + * `createRouter({ history })`. They are re-declared here (instead of imported) + * to keep the core layer free of any framework dependency — the same history + * object can back any router that accepts this W3C-History-like contract. + */ + +/** A history location, serialized as a full path string (`/users/1?tab=posts#bio`). */ +export type HistoryLocation = string + +/** + * Values allowed in history state entries. Mirrors what the HTML5 + * `history.pushState` structured-clone contract accepts (minus non-JSON + * exotica), because cross-container state must survive URL/JSON transport. + */ +export type HistoryStateValue = + | string + | number + | boolean + | null + | undefined + | HistoryState + | HistoryStateArray + +/** State object associated with a history entry, like HTML `history.state`. */ +export interface HistoryState { + [x: number]: HistoryStateValue + [x: string]: HistoryStateValue +} + +export interface HistoryStateArray extends Array {} + +/** Starting location for histories, mirrors vue-router's `START`. */ +export const START: HistoryLocation = '' + +export type NavigationType = 'pop' | 'push' +export type NavigationDirection = 'back' | 'forward' | '' + +export interface NavigationInformation { + type: NavigationType + direction: NavigationDirection + delta: number +} + +export interface NavigationCallback { + ( + to: HistoryLocation, + from: HistoryLocation, + information: NavigationInformation + ): void +} + +/** + * The framework-facing history contract. Structurally compatible with + * vue-router's `RouterHistory`, with hybrid-navigation extensions. + */ +export interface HybridRouterHistory { + /** Base prepended to every url; kept for RouterHistory compatibility. */ + readonly base: string + /** Current history location (within this container). */ + readonly location: HistoryLocation + /** Current history state (within this container). */ + readonly state: HistoryState + + /** In-container navigation, like `history.pushState`. */ + push(to: HistoryLocation, data?: HistoryState): void + /** In-container navigation, like `history.replaceState`. */ + replace(to: HistoryLocation, data?: HistoryState): void + /** + * Traverse history. Deltas are consumed by the in-container queue first; + * any remainder walks the native container stack via + * {@link NavigationHost.close} (each container counts as one entry). + * Positive deltas beyond the local queue cannot re-enter closed containers + * and are clamped (MPA limitation). + */ + go(delta: number, triggerListeners?: boolean): void + /** Listen to `popstate`-like traversals within this container. */ + listen(callback: NavigationCallback): () => void + /** Generate the href for a location: a platform URL (sparkling scheme). */ + createHref(location: HistoryLocation): string + /** Remove all listeners and host subscriptions. */ + destroy(): void + + // ── Extensions beyond vue-router's RouterHistory ───────────────────── + + /** + * Cross-container navigation: opens `to` in a NEW container via the host + * (the MPA equivalent of `window.open`/link navigation). The current + * container keeps its own history untouched — it stays alive underneath + * the new page, exactly like a native navigation stack. + * + * @param to - target location (must be resolvable by the codec) + * @param data - state to hand to the target container's history + * @param options - `replace` swaps the current container instead of stacking + */ + pushExternal( + to: HistoryLocation, + data?: HistoryState, + options?: { replace?: boolean } + ): Promise + + /** Depth of this container in the native stack (0 = root). */ + readonly depth: number + + /** The page bundle this container runs, when the initial URL reveals it. */ + readonly bundle: string | null + + /** + * `pageshow`-like hook: fires when this container becomes visible again + * (e.g. the page stacked on top of it was closed). This is intentionally + * NOT a `popstate` equivalent — the container's own location did not + * change — but routers/apps can use it to refresh data. + */ + onRestore(callback: (info: { visible: boolean }) => void): () => void +} + +// ── Host contract ────────────────────────────────────────────────────── + +export interface NavigationHostOpenOptions { + /** Replace the current container instead of stacking a new one. */ + replace?: boolean +} + +export interface NavigationHostCloseOptions { + /** How many containers to pop. Hosts MAY only support 1. Default 1. */ + count?: number +} + +/** + * Minimal platform contract the history shim runs on. Anything that can + * open/close stacked pages by URL can implement this — Sparkling native + * containers, the Sparkling web shell, or an in-memory simulator for tests. + * + * Implementations for other stacks (e.g. a bespoke native shell) only need + * to fulfil this interface to reuse the whole shim + router adapters. + */ +export interface NavigationHost { + /** URL this container was opened with (e.g. `hybrid://lynxview_page?...`). */ + readonly initialUrl: string + /** Unique id of this container instance, if the platform provides one. */ + readonly containerId: string + /** Open a new container for the given platform URL. */ + open(url: string, options?: NavigationHostOpenOptions): Promise + /** Close containers starting from this one (pop the native stack). */ + close(options?: NavigationHostCloseOptions): Promise + /** + * Subscribe to this container's visibility (viewAppeared/viewDisappeared + * on Sparkling). Optional: hosts without lifecycle events can omit it. + */ + onVisibilityChange?(callback: (visible: boolean) => void): () => void +} + +// ── URL codec contract ───────────────────────────────────────────────── + +/** Result of decoding a platform URL back into router-world coordinates. */ +export interface DecodedLocation { + location: HistoryLocation + state?: HistoryState + /** Depth of the container in the native stack (0 = root). */ + depth: number + /** Page bundle this container is running, when the URL reveals it. */ + bundle?: string +} + +/** + * Bidirectional mapping between router locations (`/users/1?tab=posts`) and + * platform URLs (`hybrid://lynxview_page?bundle=user.lynx.bundle&...`). + * + * This is where the file-based route manifest plugs in: the codec must know + * which page bundle owns which route so separate JS heaps agree on the + * mapping without sharing memory. + */ +export interface SchemeCodec { + /** Router location -> platform URL. Returns null if no page owns `to`. */ + encode( + to: HistoryLocation, + context?: { state?: HistoryState; depth?: number } + ): string | null + /** Platform URL -> router location (+ state/depth riding along). */ + decode(url: string): DecodedLocation | null + /** Name of the page bundle owning `to`, or null if none matches. */ + ownerOf(to: HistoryLocation): string | null +} diff --git a/packages/sparkling-history/src/vue/index.ts b/packages/sparkling-history/src/vue/index.ts new file mode 100644 index 00000000..8a2d69bc --- /dev/null +++ b/packages/sparkling-history/src/vue/index.ts @@ -0,0 +1,176 @@ +// 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 { + createRouter, + type NavigationFailure, + type RouteLocationRaw, + type Router, + type RouterHistory, + type RouterOptions, +} from 'vue-router' +import { createHybridHistory } from '../history' +import { createSparklingSchemeCodec } from '../codec' +import { + fillPattern, + findByName, + type RouteManifest, +} from '../manifest' +import type { + HistoryState, + HybridRouterHistory, + NavigationHost, + SchemeCodec, +} from '../types' +import { createSparklingNavigationHost } from '../hosts/sparkling' + +/** + * vue-router adapter. + * + * `createSparklingRouter` builds a standard vue-router `Router` whose history + * is a {@link HybridRouterHistory}. Navigations that stay within this page + * bundle behave exactly like a normal (memory-history) vue-router SPA: + * nested routes, guards, `router-view` — everything runs in this heap. + * + * Navigations that RESOLVE TO A ROUTE OWNED BY ANOTHER PAGE BUNDLE (per the + * shared route manifest) are diverted to the platform: `router.push('/x')` + * becomes a sparkling `router.open` that stacks a new native container. The + * current page keeps its state underneath — this is MPA navigation, so the + * local router does NOT transition (its guards do not run for the new page; + * the new page's own router runs its own guards on startup, exactly like a + * fresh document would in a browser MPA). + */ + +export interface SparklingRouterOptions extends Omit { + /** The shared route manifest (generated via `sparkling-history/codegen`). */ + manifest: RouteManifest + /** Platform host. Defaults to the Sparkling container host. */ + host?: NavigationHost + /** URL codec. Defaults to the sparkling scheme codec over `manifest`. */ + codec?: SchemeCodec + /** Pre-built history. Overrides `host`/`codec` when provided. */ + history?: HybridRouterHistory +} + +export interface SparklingRouter extends Router { + /** The underlying hybrid history, exposing `depth`, `onRestore`, etc. */ + readonly hybridHistory: HybridRouterHistory +} + +export function createSparklingRouter( + options: SparklingRouterOptions +): SparklingRouter { + const { manifest, host, codec, history, ...routerOptions } = options + + const resolvedCodec = + codec ?? createSparklingSchemeCodec(manifest) + const resolvedHistory = + history ?? + createHybridHistory({ + host: + host ?? + createSparklingNavigationHost({ scheme: manifest.scheme }), + codec: resolvedCodec, + }) + + const router = createRouter({ + ...routerOptions, + // HybridRouterHistory is structurally compatible with RouterHistory; + // the cast bridges vue-router's enum-typed NavigationInformation. + history: resolvedHistory as unknown as RouterHistory, + }) as SparklingRouter + + Object.defineProperty(router, 'hybridHistory', { + enumerable: true, + get: () => resolvedHistory, + }) + + const ownBundle = resolvedHistory.bundle + + /** + * Resolve `to` far enough to decide ownership. Returns null when the + * navigation should stay local (own bundle, unknown routes, hash-only...). + */ + function resolveCrossPage( + to: RouteLocationRaw + ): { fullPath: string; state?: HistoryState } | null { + let fullPath: string | null = null + let state: HistoryState | undefined + + if (typeof to === 'object' && to !== null) { + state = (to as { state?: HistoryState }).state + } + + // Named route living in another bundle: local matcher can't resolve it, + // so fill the pattern straight from the manifest. + const name = typeof to === 'object' && to && 'name' in to ? to.name : null + if (name != null && !router.hasRoute(name as never)) { + const named = findByName(manifest, String(name)) + if (named && named.page.bundle !== ownBundle) { + const target = to as { + params?: Record + query?: Record + hash?: string + } + fullPath = fillPattern(named.pattern, target.params) + const query = stringifyQuery(target.query) + if (query) fullPath += '?' + query + if (target.hash) fullPath += target.hash + return { fullPath, state } + } + return null // unknown name: let vue-router throw its usual error + } + + let resolved + try { + resolved = router.resolve(to) + } catch { + return null + } + const owner = resolvedCodec.ownerOf(resolved.fullPath) + if (owner === null || owner === ownBundle) return null + return { fullPath: resolved.fullPath, state } + } + + const originalPush = router.push.bind(router) + const originalReplace = router.replace.bind(router) + + router.push = (to: RouteLocationRaw) => { + const crossPage = resolveCrossPage(to) + if (crossPage) { + return resolvedHistory + .pushExternal(crossPage.fullPath, crossPage.state) + .then(() => undefined as unknown as NavigationFailure | void) + } + return originalPush(to) + } + + router.replace = (to: RouteLocationRaw) => { + const crossPage = resolveCrossPage(to) + if (crossPage) { + return resolvedHistory + .pushExternal(crossPage.fullPath, crossPage.state, { replace: true }) + .then(() => undefined as unknown as NavigationFailure | void) + } + return originalReplace(to) + } + + return router +} + +function stringifyQuery(query?: Record): string { + if (!query) return '' + return Object.entries(query) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const values = Array.isArray(value) ? value : [value] + return values + .map( + v => + `${encodeURIComponent(key)}=${encodeURIComponent(String(v))}` + ) + .join('&') + }) + .join('&') +} diff --git a/packages/sparkling-history/tsconfig.json b/packages/sparkling-history/tsconfig.json new file mode 100644 index 00000000..cbab0dca --- /dev/null +++ b/packages/sparkling-history/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/__tests__", "dist", "node_modules"] +} diff --git a/packages/sparkling-history/vitest.config.ts b/packages/sparkling-history/vitest.config.ts new file mode 100644 index 00000000..257508b6 --- /dev/null +++ b/packages/sparkling-history/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.spec.ts'], + }, +}) 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..8bdf564d --- /dev/null +++ b/packages/sparkling-web-shell/package.json @@ -0,0 +1,33 @@ +{ + "name": "sparkling-web-shell", + "version": "2.0.0-rc.6", + "private": true, + "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/nichenqin/sparkling", + "directory": "packages/sparkling-web-shell" + }, + "scripts": { + "dev": "rsbuild dev", + "build": "rsbuild build", + "preview": "rsbuild preview" + }, + "dependencies": { + "@lynx-js/web-core": "0.19.8", + "@lynx-js/web-elements": "0.11.3", + "sparkling-method": "workspace:*", + "sparkling-navigation": "workspace:*", + "sparkling-storage": "workspace:*", + "sparkling-media": "workspace:*" + }, + "devDependencies": { + "@rsbuild/core": "1.7.2", + "typescript": "^5.8.3" + }, + "engines": { + "node": "^22 || ^24" + } +} diff --git a/packages/sparkling-web-shell/rsbuild.config.ts b/packages/sparkling-web-shell/rsbuild.config.ts new file mode 100644 index 00000000..bee4dac1 --- /dev/null +++ b/packages/sparkling-web-shell/rsbuild.config.ts @@ -0,0 +1,40 @@ +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'); + +export default defineConfig({ + source: { + entry: { + index: './src/index.ts', + }, + }, + html: { + title: 'Sparkling Web Preview', + template: './src/index.html', + }, + server: { + port: 4200, + publicDir: [ + { + name: path.resolve(bundleDir), + // Serve the example bundles in dev only. The static build must NOT + // carry any example's bundles — when embedded it loads them at runtime + // from `?base=`, so one build serves every example. + copyOnBuild: false, + }, + ], + }, + output: { + // Relative asset paths ('auto') let the built shell be dropped under any + // path (e.g. the docs site's /mpa-preview/) and embedded in an