diff --git a/docs/en/apis/index.mdx b/docs/en/apis/index.mdx index a6b41752..80998c05 100644 --- a/docs/en/apis/index.mdx +++ b/docs/en/apis/index.mdx @@ -4,13 +4,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 660a4d6c..8ef2e75c 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: `android`, `ios`, `web`, or `all` (default: `all`) | | `--port ` | Dev server port (default: `5969`) | The default port **5969** spells **LYNX** on a phone keypad (L=5, Y=9, N=6, X=9). @@ -71,12 +75,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** — Updates `settings.gradle(.kts)` and `app/build.gradle(.kts)` with module includes/dependencies, and generates `SparklingAutolink.kt`. - **iOS** — Updates the `Podfile` with pod entries and generates `SparklingAutolink.swift`. +- **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` @@ -124,6 +129,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. @@ -180,6 +203,9 @@ npx sparkling run:android # 5. Run on iOS npx sparkling run:ios -# 6. Build bundles for release +# 6. Preview in browser +npx sparkling run:web + +# 7. Build bundles for release npx sparkling build --copy ``` diff --git a/docs/en/guide/get-started/create-custom-method.md b/docs/en/guide/get-started/create-custom-method.md index f99395e5..4f3cbd3b 100644 --- a/docs/en/guide/get-started/create-custom-method.md +++ b/docs/en/guide/get-started/create-custom-method.md @@ -252,6 +252,17 @@ npm install sparkling-my-greeting npx sparkling autolink ``` +## 7. 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.md b/docs/en/guide/get-started/create-new-app.md index cffa4238..991a7450 100644 --- a/docs/en/guide/get-started/create-new-app.md +++ b/docs/en/guide/get-started/create-new-app.md @@ -14,7 +14,7 @@ npm create sparkling-app@latest my-app cd my-app ``` -2) Run native targets: +2) Run on any platform: ```bash # Android @@ -22,6 +22,9 @@ npm run run:android # iOS npm run run:ios + +# Web (browser preview) +npm run run:web ``` 3) Add Sparkling methods as needed: @@ -41,6 +44,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) @@ -49,7 +53,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/package.json b/package.json index 16c343a7..1d0b4e2e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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 79b23f9b..9125b2c5 100644 --- a/packages/create-sparkling-app/src/create-app.ts +++ b/packages/create-sparkling-app/src/create-app.ts @@ -46,6 +46,7 @@ import { askNamespace, askProjectName, askTemplate, + askWebPlatform, confirmInitGit, confirmInstall, confirmRemoveExistingDir, @@ -74,6 +75,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 @@ -194,6 +234,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); @@ -277,6 +319,9 @@ export async function createSparklingApp( }); applyPackageNamespace(config.targetDir, packageNamespace); ensureExecutable(path.join(config.targetDir, "android", "gradlew")); + if (!enableWeb) { + removeWebSupport(config.targetDir); + } }, }); @@ -332,5 +377,5 @@ export async function createSparklingApp( await initializeGitRepo(distFolder); } - showCompletionNotes(targetDir, packageManager, didInstall); + showCompletionNotes(targetDir, packageManager, didInstall, enableWeb); } 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 025dd4f0..818e60cc 100644 --- a/packages/create-sparkling-app/src/create-app/post-create.ts +++ b/packages/create-sparkling-app/src/create-app/post-create.ts @@ -111,7 +111,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 { console.log(ui.success(`✔ Project created at ${targetDir}`)); const formatScriptCommand = (script: string) => { @@ -129,6 +129,9 @@ export function showCompletionNotes(targetDir: string, packageManager?: string, nextSteps.push(formatScriptCommand('run:ios')); nextSteps.push(formatScriptCommand('run:android')); + if (enableWeb) { + nextSteps.push(formatScriptCommand('run:web')); + } console.log(ui.headline('Next steps')); nextSteps.forEach(step => console.log(ui.headline(step))); @@ -137,6 +140,9 @@ export function showCompletionNotes(targetDir: string, packageManager?: string, '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.'); + } tips.forEach(tip => { console.log(ui.tip(tip)); }); 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 74c286b4..32a1418b 100644 --- a/packages/create-sparkling-app/src/create-app/user-prompts.ts +++ b/packages/create-sparkling-app/src/create-app/user-prompts.ts @@ -152,6 +152,26 @@ export async function askAdditionalTools(flags: { yes?: boolean }): Promise { + if (flags.web !== undefined) return flags.web; + if (!flags.yes) { + try { + const { enableWeb } = await inquirer.prompt<{ enableWeb: boolean }>([ + { + type: 'confirm', + name: 'enableWeb', + message: ui.prompt('Enable web platform support? (preview in browser)'), + default: true, + }, + ]); + return checkCancel(enableWeb); + } catch (error) { + handlePromptError(error); + } + } + 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 50f9fa33..e2d4779e 100644 --- a/packages/methods/sparkling-media/module.config.json +++ b/packages/methods/sparkling-media/module.config.json @@ -1,6 +1,40 @@ { + "name": "sparkling-media", + "platforms": ["android", "ios", "web"], + "version": "1.0.0", + "description": "Media methods for choosing, uploading, and downloading media files in Sparkling apps", "packageName": "com.tiktok.sparkling.method", "moduleName": "Media", "androidDsl": "kts", - "projectName": "sparkling-media" + "projectName": "sparkling-media", + "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" + }, + "web": { + "entryPoint": "index.ts", + "bundleName": "media.bundle" + } } diff --git a/packages/methods/sparkling-media/package.json b/packages/methods/sparkling-media/package.json index 3e9d3ef1..6a249091 100644 --- a/packages/methods/sparkling-media/package.json +++ b/packages/methods/sparkling-media/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-media/src/web/index.ts b/packages/methods/sparkling-media/src/web/index.ts new file mode 100644 index 00000000..2c644ea0 --- /dev/null +++ b/packages/methods/sparkling-media/src/web/index.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2025 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; +import type { WebMethodHandler } from 'sparkling-method'; + +registerWebMethod('media.chooseMedia', (params, callback) => { + const data = params.data as Record | null; + const mediaTypes = data?.mediaTypes as string[] | undefined; + const sourceType = data?.sourceType as string | undefined; + const maxCount = (data?.maxCount as number) ?? 1; + + // Build accept string from mediaTypes + const acceptParts: string[] = []; + if (mediaTypes?.includes('image')) acceptParts.push('image/*'); + if (mediaTypes?.includes('video')) acceptParts.push('video/*'); + const accept = acceptParts.length ? acceptParts.join(',') : 'image/*,video/*'; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = maxCount > 1; + + // Camera source: use capture attribute (mobile browsers only) + if (sourceType === 'camera') { + input.capture = (data?.cameraType as string) === 'front' ? 'user' : 'environment'; + } + + input.onchange = () => { + const files = Array.from(input.files ?? []); + if (!files.length) { + callback({ code: 0, msg: 'No file selected' }); + return; + } + + const results = files.slice(0, maxCount).map(file => ({ + tempFilePath: URL.createObjectURL(file), + size: file.size, + type: file.type, + name: file.name, + })); + + callback({ code: 1, msg: 'ok', data: results }); + }; + + // Handle user cancelling the file picker + input.addEventListener('cancel', () => { + callback({ code: 0, msg: 'User cancelled' }); + }); + + input.click(); +}); + +registerWebMethod('media.downloadFile', (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const extension = data?.extension as string | undefined; + const headers = data?.header as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + fetch(url, { headers }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then(blob => { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = `download.${extension || 'bin'}`; + a.click(); + URL.revokeObjectURL(objectUrl); + callback({ code: 1, msg: 'ok', data: { tempFilePath: objectUrl } }); + }) + .catch(e => { + callback({ code: 0, msg: `Download failed: ${e}` }); + }); +}); + +// Shared implementation for uploadFile and uploadImage (same API shape) +const uploadFileImpl: WebMethodHandler = (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const filePath = data?.filePath as string | undefined; + const headers = data?.header as Record | undefined; + const extraParams = data?.params as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + // filePath on web is a blob URL from chooseMedia + const filePromise = filePath + ? fetch(filePath).then(r => r.blob()) + : Promise.resolve(new Blob()); + + filePromise + .then(blob => { + const formData = new FormData(); + formData.append('file', blob); + if (extraParams && typeof extraParams === 'object') { + for (const [k, v] of Object.entries(extraParams)) { + formData.append(k, String(v)); + } + } + return fetch(url, { method: 'POST', headers, body: formData }); + }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then(json => callback({ code: 1, msg: 'ok', data: json })) + .catch(e => callback({ code: 0, msg: `Upload failed: ${e}` })); +}; + +registerWebMethod('media.uploadFile', uploadFileImpl); +registerWebMethod('media.uploadImage', uploadFileImpl); + +registerWebMethod('media.saveDataURL', (_params, callback) => { + // No web API to save directly to device photo album + callback({ code: 0, msg: 'media.saveDataURL is not supported on web' }); +}); diff --git a/packages/methods/sparkling-navigation/module.config.json b/packages/methods/sparkling-navigation/module.config.json index 7712b487..c11cd638 100644 --- a/packages/methods/sparkling-navigation/module.config.json +++ b/packages/methods/sparkling-navigation/module.config.json @@ -1,6 +1,6 @@ { "name": "sparkling-navigation", - "platforms": ["android", "ios"], + "platforms": ["android", "ios", "web"], "version": "1.0.0", "description": "Router methods for navigation and page management in Sparkling apps", "methods": { diff --git a/packages/methods/sparkling-navigation/package.json b/packages/methods/sparkling-navigation/package.json index ffec18d7..0b3c51a4 100644 --- a/packages/methods/sparkling-navigation/package.json +++ b/packages/methods/sparkling-navigation/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-navigation/src/web/index.ts b/packages/methods/sparkling-navigation/src/web/index.ts new file mode 100644 index 00000000..7088e683 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/web/index.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; + +registerWebMethod('router.open', (params, callback) => { + const scheme = (params.data as Record)?.scheme as string | undefined; + + if (!scheme) { + callback({ code: 0, msg: 'scheme is required' }); + return; + } + + try { + const url = new URL(scheme); + const bundleParam = url.searchParams.get('bundle'); + const urlParam = url.searchParams.get('url'); + + // Extract page name (without extension). + // Web shell constructs the full URL as /${page}.lynx.bundle + let pageName: string; + if (urlParam) { + // Dev mode: url param is a full URL, extract the basename + const urlPath = new URL(urlParam).pathname; + pageName = urlPath.replace(/^\//, '').replace(/\.lynx\.bundle$/, ''); + } else if (bundleParam) { + pageName = bundleParam.replace(/\.lynx\.bundle$/, ''); + } else { + callback({ code: 0, msg: 'No bundle or url param in scheme' }); + return; + } + + // Push browser history state + const state = { page: pageName, scheme }; + window.history.pushState(state, '', `?page=${encodeURIComponent(pageName)}`); + + // Dispatch custom event for web shell to swap + window.dispatchEvent(new CustomEvent('sparkling:navigate', { + detail: { page: pageName, state }, + })); + + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `Failed to parse scheme: ${e}` }); + } +}); + +registerWebMethod('router.close', (_params, callback) => { + window.history.back(); + callback({ code: 1, msg: 'ok' }); +}); diff --git a/packages/methods/sparkling-storage/package.json b/packages/methods/sparkling-storage/package.json index 1facbaf1..ae682f64 100644 --- a/packages/methods/sparkling-storage/package.json +++ b/packages/methods/sparkling-storage/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-storage/src/web/index.ts b/packages/methods/sparkling-storage/src/web/index.ts new file mode 100644 index 00000000..e99eaaf5 --- /dev/null +++ b/packages/methods/sparkling-storage/src/web/index.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; + +function storageKey(key: string, biz?: string): string { + return biz ? `sparkling:${biz}:${key}` : `sparkling:${key}`; +} + +registerWebMethod('storage.getItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + const value = localStorage.getItem(storageKey(key, biz)); + callback({ code: 1, msg: 'ok', data: value }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); + +registerWebMethod('storage.setItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const value = data?.data; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + localStorage.setItem( + storageKey(key, biz), + typeof value === 'string' ? value : JSON.stringify(value), + ); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); + +registerWebMethod('storage.removeItem', (params, callback) => { + const data = params.data as Record | null; + const key = data?.key as string | undefined; + const biz = data?.biz as string | undefined; + + if (!key) { + callback({ code: 0, msg: 'key is required' }); + return; + } + + try { + localStorage.removeItem(storageKey(key, biz)); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `localStorage error: ${e}` }); + } +}); diff --git a/packages/playground/android/app/src/main/assets/card-view.lynx.bundle b/packages/playground/android/app/src/main/assets/card-view.lynx.bundle index 75e9bb16..4e021ce9 100644 Binary files a/packages/playground/android/app/src/main/assets/card-view.lynx.bundle and b/packages/playground/android/app/src/main/assets/card-view.lynx.bundle differ diff --git a/packages/playground/android/app/src/main/assets/main.lynx.bundle b/packages/playground/android/app/src/main/assets/main.lynx.bundle index 8d59258b..dc9b38fa 100644 Binary files a/packages/playground/android/app/src/main/assets/main.lynx.bundle and b/packages/playground/android/app/src/main/assets/main.lynx.bundle differ diff --git a/packages/playground/android/app/src/main/assets/media-test.lynx.bundle b/packages/playground/android/app/src/main/assets/media-test.lynx.bundle index 82e578e4..8862cfc8 100644 Binary files a/packages/playground/android/app/src/main/assets/media-test.lynx.bundle and b/packages/playground/android/app/src/main/assets/media-test.lynx.bundle differ diff --git a/packages/playground/android/app/src/main/assets/second.lynx.bundle b/packages/playground/android/app/src/main/assets/second.lynx.bundle index 23863b4d..969db6ce 100644 Binary files a/packages/playground/android/app/src/main/assets/second.lynx.bundle and b/packages/playground/android/app/src/main/assets/second.lynx.bundle differ diff --git a/packages/playground/ios/LynxResources/card-view.lynx.bundle b/packages/playground/ios/LynxResources/card-view.lynx.bundle index 75e9bb16..4e021ce9 100644 Binary files a/packages/playground/ios/LynxResources/card-view.lynx.bundle and b/packages/playground/ios/LynxResources/card-view.lynx.bundle differ diff --git a/packages/playground/ios/LynxResources/main.lynx.bundle b/packages/playground/ios/LynxResources/main.lynx.bundle index 69c2776c..dc9b38fa 100644 Binary files a/packages/playground/ios/LynxResources/main.lynx.bundle and b/packages/playground/ios/LynxResources/main.lynx.bundle differ diff --git a/packages/playground/ios/LynxResources/media-test.lynx.bundle b/packages/playground/ios/LynxResources/media-test.lynx.bundle index 82e578e4..8862cfc8 100644 Binary files a/packages/playground/ios/LynxResources/media-test.lynx.bundle and b/packages/playground/ios/LynxResources/media-test.lynx.bundle differ diff --git a/packages/playground/ios/LynxResources/second.lynx.bundle b/packages/playground/ios/LynxResources/second.lynx.bundle index 620e5c52..969db6ce 100644 Binary files a/packages/playground/ios/LynxResources/second.lynx.bundle and b/packages/playground/ios/LynxResources/second.lynx.bundle differ diff --git a/packages/playground/lynx.config.ts b/packages/playground/lynx.config.ts index 84341279..9e3345ca 100644 --- a/packages/playground/lynx.config.ts +++ b/packages/playground/lynx.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from '@lynx-js/rspeedy' import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin' import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' -function copyDir(src: string, dest: string) { +function copyDir(src: string, dest: string, filter?: (name: string) => boolean) { if (!fs.existsSync(src)) { console.warn(`Source directory ${src} does not exist, skipping copy`) return @@ -17,11 +17,15 @@ function copyDir(src: string, dest: string) { const entries = fs.readdirSync(src, { withFileTypes: true }) for (const entry of entries) { + if (filter && !filter(entry.name)) { + continue + } + const srcPath = path.join(src, entry.name) const destPath = path.join(dest, entry.name) if (entry.isDirectory()) { - copyDir(srcPath, destPath) + copyDir(srcPath, destPath, filter) } else { fs.copyFileSync(srcPath, destPath) } @@ -38,11 +42,25 @@ export default defineConfig({ }, }, output: { - assetPrefix: 'asset:///', filename: { bundle: '[name].lynx.bundle', }, }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: { + output: { + assetPrefix: 'asset:///', + }, + }, + }, plugins: [ pluginQRCode({ schema(url) { @@ -59,11 +77,14 @@ export default defineConfig({ const androidDest = 'android/app/src/main/assets' const iosDest = 'ios/LynxResources' + // Skip the web subdirectory when copying to native asset dirs + const nativeFilter = (name: string) => name !== 'web' + console.log(`Copying ${sourceDir} to Android (${androidDest})...`) - copyDir(sourceDir, androidDest) + copyDir(sourceDir, androidDest, nativeFilter) console.log(`Copying ${sourceDir} to iOS (${iosDest})...`) - copyDir(sourceDir, iosDest) + copyDir(sourceDir, iosDest, nativeFilter) console.log('Assets copied successfully!') }) diff --git a/packages/playground/package.json b/packages/playground/package.json index 04178977..945640ed 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -11,7 +11,9 @@ }, "scripts": { "build": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-storage --filter sparkling-media build && rspeedy build", + "build:web": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-storage --filter sparkling-media build && rspeedy build --environment web", "dev": "rspeedy dev", + "dev:web": "rspeedy build --environment web && pnpm --filter sparkling-web-shell dev", "format": "prettier --write .", "preview": "rspeedy preview" }, diff --git a/packages/sparkling-app-cli/src/commands/autolink.ts b/packages/sparkling-app-cli/src/commands/autolink.ts index b8288b77..857b9f5a 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'; } /** @@ -248,6 +248,8 @@ async function discoverModules(cwd: string): Promise { const androidConfig = config.android as Record | undefined; const iosConfig = config.ios as Record | 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) @@ -267,6 +269,7 @@ async function discoverModules(cwd: string): Promise { seen.set(name, { name, root: moduleRoot, + platforms, android: { packageName: typeof androidConfig?.packageName === 'string' ? androidConfig.packageName : undefined, className: typeof androidConfig?.className === 'string' ? androidConfig.className : undefined, @@ -278,6 +281,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, }); } } @@ -774,14 +781,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'; const 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. @@ -844,7 +887,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 ad26e531..0c07a5ea 100644 --- a/packages/sparkling-app-cli/src/commands/dev.ts +++ b/packages/sparkling-app-cli/src/commands/dev.ts @@ -8,23 +8,37 @@ import { runCommand } from '../utils/exec'; import { ui } from '../utils/ui'; import { isVerboseEnabled, verboseLog } from '../utils/verbose'; +export type DevPlatform = 'web' | 'native' | 'all'; + export interface DevOptions { cwd: string; configFile?: string; port?: number; + platform?: DevPlatform; } export async function devProject(options: DevOptions): Promise { const configPath = path.resolve(options.cwd, options.configFile ?? 'app.config.ts'); const port = options.port ?? DEV_SERVER_PORT; + const platform = options.platform ?? 'all'; const tempConfigPath = createDevLynxConfig(options.cwd, configPath, port); if (isVerboseEnabled()) { verboseLog(`App config path: ${configPath}`); verboseLog(`Temp Lynx config: ${tempConfigPath}`); verboseLog(`Dev server port: ${port}`); + verboseLog(`Dev platform: ${platform}`); + } + + 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 - console.log(ui.headline(`Starting Rspeedy dev server on port ${port} with config from ${path.relative(options.cwd, configPath)}`)); - await runCommand('rspeedy', ['dev', '--config', tempConfigPath], { cwd: options.cwd }); + console.log(ui.headline(`Starting Rspeedy dev server (${platform}) on port ${port} with config from ${path.relative(options.cwd, configPath)}`)); + await runCommand('rspeedy', rspeedyArgs, { cwd: options.cwd }); } 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 8d7af475..bd66e711 100644 --- a/packages/sparkling-app-cli/src/index.ts +++ b/packages/sparkling-app-cli/src/index.ts @@ -6,11 +6,14 @@ import { Command } from 'commander'; import path from 'node:path'; import { autolink } from './commands/autolink'; import { buildProject } from './commands/build'; +import type { BuildPlatform } from './commands/build'; import { copyAssets } from './commands/copy-assets'; import { devProject } from './commands/dev'; +import type { DevPlatform } from './commands/dev'; import { doctor } from './commands/doctor'; import { runAndroid } from './commands/run-android'; import { runIos } from './commands/run-ios'; +import { runWeb } from './commands/run-web'; import { ui } from './utils/ui'; import { enableVerboseLogging, isVerboseEnabled, verboseLog } from './utils/verbose'; import type { AppConfig } from './types'; @@ -40,26 +43,43 @@ function resolveSkipCopy(opts: { copy?: boolean; skipCopy?: boolean }): boolean return true; } +const BUILD_PLATFORMS = ['android', 'ios', 'web', 'native', 'all']; +const DEV_PLATFORMS = ['web', 'native', 'all']; +const AUTOLINK_PLATFORMS = ['android', 'ios', 'web', 'all']; +const DOCTOR_PLATFORMS = ['android', 'ios', 'web', 'all']; + program .command('build') .description('Build Lynx bundle using app.config.ts (no-copy by default)') .option('--config ', 'Path to app.config.ts', 'app.config.ts') + .option('--platform ', 'Target platform: android|ios|web|native|all', 'all') .option('--copy', 'Copy assets to native shells') .option('--skip-copy', 'Skip copying assets to native shells') .action(async opts => { const cwd = process.cwd(); const skipCopy = resolveSkipCopy(opts); - await buildProject({ cwd, configFile: opts.config, skipCopy }); + const raw = String(opts.platform ?? 'all').toLowerCase(); + const platform = (BUILD_PLATFORMS.includes(raw) ? raw : 'all') as BuildPlatform; + if (!BUILD_PLATFORMS.includes(raw)) { + console.warn(ui.warn(`Unknown platform "${opts.platform}", defaulting to 'all'.`)); + } + await buildProject({ cwd, configFile: opts.config, skipCopy, platform }); }); program .command('dev') .description('Start Rspeedy dev server using app.config.ts') .option('--config ', 'Path to app.config.ts', 'app.config.ts') + .option('--platform ', 'Target platform: web|native|all', 'all') .option('--port ', 'Dev server port (default: 5969)', '5969') .action(async opts => { const cwd = process.cwd(); - await devProject({ cwd, configFile: opts.config, port: Number(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: Number(opts.port), platform }); }); program @@ -68,6 +88,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({ @@ -75,19 +96,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 }); @@ -122,15 +143,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 82e13174..4292f73a 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; @@ -46,6 +49,7 @@ export interface AppConfig { paths?: { androidAssets?: string; iosAssets?: string; + webAssets?: string; }; appIcon?: string; router?: RouterConfig; @@ -55,6 +59,7 @@ export interface AppConfig { export interface MethodModuleConfig { name: string; root: string; + platforms?: string[]; android?: { packageName?: string; className?: string; @@ -66,4 +71,8 @@ export interface MethodModuleConfig { className?: string; podspecPath?: string; }; + web?: { + entryPoint?: string; + subpath?: string; + }; } diff --git a/packages/sparkling-method/package.json b/packages/sparkling-method/package.json index 59300494..2a2f8a01 100644 --- a/packages/sparkling-method/package.json +++ b/packages/sparkling-method/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web-registry": { + "types": "./dist/web-registry.d.ts", + "default": "./dist/web-registry.js" + } + }, + "typesVersions": { + "*": { + "web-registry": ["dist/web-registry.d.ts"] + } + }, "scripts": { "build": "tsc && node -e \"require('fs').copyFileSync('src/typing.d.ts', 'dist/typing.d.ts')\"" }, diff --git a/packages/sparkling-method/src/index.ts b/packages/sparkling-method/src/index.ts index 3c02e38f..669487b1 100644 --- a/packages/sparkling-method/src/index.ts +++ b/packages/sparkling-method/src/index.ts @@ -4,7 +4,8 @@ /// -import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback } from './types'; +import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback, WebMethodHandler } from './types'; +import { getWebMethodHandler } from './web-registry'; export type { PipeResponse, @@ -12,6 +13,7 @@ export type { PipeCallOptions, MethodMap, EventCallback, + WebMethodHandler, }; /** @@ -91,6 +93,28 @@ const LynxPipe = { return; } + // Web handler dispatch — if a handler is registered, use it. + // On native the registry is empty (no /web imports), so this is a no-op. + // On web, @lynx-js/web-core provides a NativeModules stub without spkPipe, + // so we must dispatch here before the NativeModules checks below. + const webHandler = getWebMethodHandler(method); + if (webHandler) { + try { + webHandler( + { + containerID: getContainerID(), + protocolVersion: '1.0.0', + data: params ?? null, + }, + callback + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + callback(createErrorResponse(-5, `Web pipe call failed: ${errorMsg}`)); + } + return; + } + // Check if NativeModules is available if (typeof NativeModules === 'undefined') { callback(createErrorResponse(-2, 'NativeModules is not available. Ensure you are running in a lynx environment.')); diff --git a/packages/sparkling-method/src/types.ts b/packages/sparkling-method/src/types.ts index 08d3fd63..c7817da2 100644 --- a/packages/sparkling-method/src/types.ts +++ b/packages/sparkling-method/src/types.ts @@ -42,3 +42,13 @@ export type MethodMap = string | { * Pipe event callback */ export type EventCallback = (event: unknown) => void; + +/** + * Handler function for a web method implementation. + * Receives the same envelope that native modules receive and must + * invoke the callback with a `{ code, msg, data? }` response. + */ +export type WebMethodHandler = ( + params: { containerID: string; protocolVersion: string; data: unknown }, + callback: (response: { code: number; msg: string; data?: unknown }) => void, +) => void; diff --git a/packages/sparkling-method/src/web-registry.ts b/packages/sparkling-method/src/web-registry.ts new file mode 100644 index 00000000..1c15013c --- /dev/null +++ b/packages/sparkling-method/src/web-registry.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { WebMethodHandler } from './types'; + +export type { WebMethodHandler }; + +const registry = new Map(); + +/** + * Register a web implementation for a method. + * Called at import time by method package `/web` subpath exports. + */ +export function registerWebMethod(methodName: string, handler: WebMethodHandler): void { + if (!methodName || typeof methodName !== 'string') { + throw new Error('methodName must be a non-empty string'); + } + if (typeof handler !== 'function') { + throw new Error('handler must be a function'); + } + registry.set(methodName, handler); +} + +/** + * Look up a registered web handler by method name. + */ +export function getWebMethodHandler(methodName: string): WebMethodHandler | undefined { + return registry.get(methodName); +} + +/** + * Check whether a web handler is registered for the given method name. + */ +export function hasWebMethod(methodName: string): boolean { + return registry.has(methodName); +} + +/** + * Detect whether the current environment is a web browser + * (as opposed to the Lynx native runtime). + * + * Note: We check for `document` rather than the absence of `NativeModules` + * because `@lynx-js/web-core` defines a `NativeModules` stub in the browser. + * Native Lynx does not provide a `document` global. + */ +export function isWebEnvironment(): boolean { + return typeof document !== 'undefined'; +} diff --git a/packages/sparkling-web-shell/.gitignore b/packages/sparkling-web-shell/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/sparkling-web-shell/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/sparkling-web-shell/package.json b/packages/sparkling-web-shell/package.json new file mode 100644 index 00000000..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..47d93787 --- /dev/null +++ b/packages/sparkling-web-shell/rsbuild.config.ts @@ -0,0 +1,33 @@ +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), + }, + ], + }, + output: { + assetPrefix: '/', + }, +}); diff --git a/packages/sparkling-web-shell/src/env.d.ts b/packages/sparkling-web-shell/src/env.d.ts new file mode 100644 index 00000000..d20f8a67 --- /dev/null +++ b/packages/sparkling-web-shell/src/env.d.ts @@ -0,0 +1,16 @@ +declare global { + interface LynxViewElement extends HTMLElement { + /** Main-thread callback for NativeModules RPC calls from the Worker */ + onNativeModulesCall?: ( + name: string, + data: unknown, + moduleName: string, + ) => Promise | unknown; + } + + interface HTMLElementTagNameMap { + 'lynx-view': LynxViewElement; + } +} + +export {}; diff --git a/packages/sparkling-web-shell/src/index.html b/packages/sparkling-web-shell/src/index.html new file mode 100644 index 00000000..e3acb93c --- /dev/null +++ b/packages/sparkling-web-shell/src/index.html @@ -0,0 +1,15 @@ + + + + + + Sparkling Web Preview + + + +
+ + diff --git a/packages/sparkling-web-shell/src/index.ts b/packages/sparkling-web-shell/src/index.ts new file mode 100644 index 00000000..494e3888 --- /dev/null +++ b/packages/sparkling-web-shell/src/index.ts @@ -0,0 +1,114 @@ +// Import Lynx web runtime and elements +import '@lynx-js/web-core'; +import '@lynx-js/web-core/index.css'; +import '@lynx-js/web-elements/all'; +import '@lynx-js/web-elements/index.css'; + +// Import web method handlers (self-registering). +// These register handlers in the main-thread registry where browser APIs +// (localStorage, window.history, document.createElement) are available. +import 'sparkling-navigation/web'; +import 'sparkling-storage/web'; +import 'sparkling-media/web'; + +import { getWebMethodHandler } from 'sparkling-method/web-registry'; + +/** + * Determine which bundle to load from the ?page= query parameter. + * Default: "main" -> /main.lynx.bundle + */ +function getBundleUrl(): string { + const params = new URLSearchParams(window.location.search); + const page = params.get('page') || 'main'; + return `/${page}.lynx.bundle`; +} + +/** + * Handle NativeModules RPC calls from the Worker thread. + * When the Lynx bundle calls NativeModules.spkPipe.call(method, data, callback), + * web-core bridges the call to this main-thread handler via onNativeModulesCall. + */ +function handleNativeModulesCall( + name: string, + data: unknown, + moduleName: string, +): Promise | unknown { + if (moduleName !== 'spkPipe') { + return undefined; + } + + const handler = getWebMethodHandler(name); + if (!handler) { + return { code: -3, msg: `Web handler not found for "${name}"` }; + } + + return new Promise((resolve) => { + handler( + data as { containerID: string; protocolVersion: string; data: unknown }, + (response) => resolve(response), + ); + }); +} + +/** + * Create a blob URL for a minimal ESM module that acts as the spkPipe + * NativeModule stub in the Worker. web-core dynamically imports this URL. + * The module exports a default object with a `call` method that the Worker's + * NativeModules will use. The actual call is bridged to the main thread + * via web-core's RPC mechanism and handled by onNativeModulesCall. + */ +const spkPipeModuleCode = ` +// Factory called by web-core's createNativeModules. +// Args: (nativeModules, callBridge) where callBridge sends RPC to main thread. +export default function(nativeModules, callBridge) { + return { + call(name, data, callback) { + callBridge(name, data).then(callback); + } + }; +}; +`; +const spkPipeBlob = new Blob([spkPipeModuleCode], { type: 'application/javascript' }); +const spkPipeModuleUrl = URL.createObjectURL(spkPipeBlob); + +/** + * Render the Lynx view into the page. + */ +function render(): void { + const bundleUrl = getBundleUrl(); + const container = document.getElementById('root'); + if (!container) { + console.error('[sparkling-web-shell] #root element not found'); + return; + } + + container.innerHTML = ''; + + const lynxView = document.createElement('lynx-view'); + lynxView.setAttribute('url', bundleUrl); + lynxView.style.width = '100vw'; + lynxView.style.height = '100vh'; + + // Register main-thread handler for spkPipe NativeModules calls. + // Must be set BEFORE adding to DOM (connectedCallback initializes the Worker). + lynxView.onNativeModulesCall = handleNativeModulesCall; + + // Register spkPipe in the native modules map so the Worker knows it exists. + const modulesMap = lynxView.nativeModulesMap as Record; + if (modulesMap) { + modulesMap['spkPipe'] = spkPipeModuleUrl; + } + + container.appendChild(lynxView); +} + +// Initial render +render(); + +// Listen for sparkling:navigate events dispatched by router.open web handler +window.addEventListener('sparkling:navigate', ((event: CustomEvent) => { + render(); +}) as EventListener); + +// Re-render when browser navigation changes the URL +window.addEventListener('popstate', render); diff --git a/packages/sparkling-web-shell/tsconfig.json b/packages/sparkling-web-shell/tsconfig.json new file mode 100644 index 00000000..9e63f58f --- /dev/null +++ b/packages/sparkling-web-shell/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/packages/website/rspress.config.ts b/packages/website/rspress.config.ts index 3e630c77..d2f1255e 100644 --- a/packages/website/rspress.config.ts +++ b/packages/website/rspress.config.ts @@ -49,6 +49,14 @@ const sidebarEn = { }, ], }, + { + text: 'Web Platform', + items: [ + { text: 'Web Platform Guide', link: '/guide/web-platform' }, + { text: 'Web Methods', link: '/guide/web-method-implementations' }, + { text: 'Limitations', link: '/guide/web-limitations' }, + ], + }, { text: 'CLI', items: [{ text: 'CLI', link: '/guide/cli' }] }, ], '/apis/': [ @@ -88,6 +96,14 @@ const sidebarZhBase = { { text: '创建自定义 Method', link: '/guide/get-started/create-custom-method' }, ], }, + { + text: 'Web 平台', + items: [ + { text: 'Web 平台指南', link: '/guide/web-platform' }, + { text: 'Web Methods', link: '/guide/web-method-implementations' }, + { text: '限制', link: '/guide/web-limitations' }, + ], + }, { text: 'CLI', items: [{ text: 'CLI', link: '/guide/cli' }] }, ], '/apis/': [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b7e7aa..041222af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + playwright: + specifier: ^1.58.2 + version: 1.58.2 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -261,6 +264,34 @@ importers: packages/sparkling-sdk: {} + packages/sparkling-web-shell: + dependencies: + '@lynx-js/web-core': + specifier: 0.19.8 + version: 0.19.8(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.11.3(tslib@2.8.1)) + '@lynx-js/web-elements': + specifier: 0.11.3 + version: 0.11.3(tslib@2.8.1) + sparkling-media: + specifier: workspace:* + version: link:../methods/sparkling-media + sparkling-method: + specifier: workspace:* + version: link:../sparkling-method + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + sparkling-storage: + specifier: workspace:* + version: link:../methods/sparkling-storage + devDependencies: + '@rsbuild/core': + specifier: 1.7.2 + version: 1.7.2 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/website: devDependencies: '@rspack/core': @@ -303,6 +334,12 @@ importers: '@lynx-js/types': specifier: ^3.6.0 version: 3.6.0 + '@lynx-js/web-core': + specifier: ^0.7.0 + version: 0.7.1(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.7.7(tslib@2.8.1)) + '@lynx-js/web-elements': + specifier: ^0.7.0 + version: 0.7.7(tslib@2.8.1) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -321,6 +358,9 @@ importers: sparkling-app-cli: specifier: ~2.0.1 version: 2.0.1(@rspack/core@1.7.5(@swc/helpers@0.5.18))(@types/node@25.2.3)(typescript@5.9.3)(webpack@5.105.0) + sparkling-web-shell: + specifier: ~2.0.1 + version: link:../../packages/sparkling-web-shell typescript: specifier: ^5.9.3 version: 5.9.3 @@ -894,6 +934,12 @@ packages: '@lynx-js/css-serializer@0.1.3': resolution: {integrity: sha512-AngMqNr8qvGeejv68MjbVNiuYAjzMm3S/t6lrCbtRn8jwa8gVU0Std2mVCMDeIoWzfjjliyRQMd6AzGydOh8dg==} + '@lynx-js/lynx-core@0.1.3': + resolution: {integrity: sha512-uWzKKYJUK4Q09ZRZxWSAFINnmZb9piWPbvWF9SkLn+3snBl9u/BJa4ekPRcKWAhBmpbtxWH1x27fxe3Q3p5l3Q==} + + '@lynx-js/offscreen-document@0.1.4': + resolution: {integrity: sha512-7OUAXZbpWigxOwpcDUU348R9Iw4JZP8qOsNdJ7+Z+LWTWt2DxbkxIKJ/KawQNOp+tsXeOFwUNbrgGkA7aqAX5w==} + '@lynx-js/qrcode-rsbuild-plugin@0.4.4': resolution: {integrity: sha512-wMZ54yYXo9VgLGPFChBICuKkj/8eBOi5vuFLQtlcOW017IUGWkH4hYZPjRei+xOryUwyT7OFqBkDf3YFSHu3+g==} engines: {node: '>=18'} @@ -965,9 +1011,68 @@ packages: peerDependencies: '@lynx-js/react': '*' + '@lynx-js/web-constants@0.19.8': + resolution: {integrity: sha512-aOk2wmwNu0jJ4ERqU+FBva20uZsQi3Uw1uRwOw8pv3fnetNhW5BlExuGdUzv6XTHbOEd1Jfm4jiOwjrzgXr0PQ==} + + '@lynx-js/web-constants@0.7.1': + resolution: {integrity: sha512-W8JmHKBwb2BGNnY8ZJBLKbKqP6cd9oUbo6ElezwFUuznDcIasxf43HSccSBpHxwghD1sdF5WrP1PFF5giIBazg==} + + '@lynx-js/web-core@0.19.8': + resolution: {integrity: sha512-8J+T7lZYraWcJNEWWLeVWjeUhRaIRPS7XL6l1x5A+5SC8QCNIezUl1fSYIJiRRQLZjph2xXvBn2K5vzQ3PJREw==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/web-elements': '>0.7.7' + + '@lynx-js/web-core@0.7.1': + resolution: {integrity: sha512-Vn6rmp0EWetm2mQMThcs9yQFRd1SxPy3+AmaapgYQLS+RIfRlQqHQmQBxWIJlUGCcq9iLFM03xw0H/YVYN3kHg==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.0 + '@lynx-js/web-elements': '>=0.1.0' + + '@lynx-js/web-elements-reactive@0.2.2': + resolution: {integrity: sha512-IVZJaD4IDtVMaFG14vwhavPH6RJPU+VgxN3NLgcRB+2DO+0Ks1/0hYAJuk/QskGF6A7haGsG9TW/EQ7mk1s/sg==} + + '@lynx-js/web-elements-template@0.7.7': + resolution: {integrity: sha512-i/Aavm6Ijk8hs7BiBxu4fjatwzkKU0s1Vbj5wdH7lo18FvCbOJZsWUuzNTwXR41D49F8oPI4hQvs0OkKv8ZUJQ==} + + '@lynx-js/web-elements@0.11.3': + resolution: {integrity: sha512-GmfJ1pyvIyR2BpFaWYlH/zlVujVMuCUj+rK20vxlza1wQdSsc5wOeBHbbY3DGq73DvtkhPEczKqIncRUQGZaMA==} + peerDependencies: + tslib: ^2.5.0 + + '@lynx-js/web-elements@0.7.7': + resolution: {integrity: sha512-8DjHNinJqQNJ2Ues7VAaf2HHGxmg7EQEiKsZ0f/u/avy0X+RNrACt42sY7VUI0+r9asC7VcRkD/QILUd9VCkNQ==} + peerDependencies: + tslib: ^2.5.0 + + '@lynx-js/web-mainthread-apis@0.19.8': + resolution: {integrity: sha512-Ze6u1wg5VZO1htXG6AVRB6CSf1qDcMrxtyl22BMmQhRvm9ut4Ck8k1+e5MIaUCTST1q1j0s59bsKsSstZ/Xlng==} + + '@lynx-js/web-mainthread-apis@0.7.1': + resolution: {integrity: sha512-xeBYM8DoVCiAN70gsq4EQs4all++zdkIWSflIkbJHMdiCWNWFeb+UNBjDLTYBfP4NwNZosnKhLArofMSne85cw==} + '@lynx-js/web-rsbuild-server-middleware@0.19.7': resolution: {integrity: sha512-5WDKvLatbn7D8cN/iuWkuuZYBPaedpe+TSfrvSSHbLD56VKclMWy6kf4qV7G5XP0f/lwj0caIMvsIuWihFB1Kw==} + '@lynx-js/web-style-transformer@0.2.2': + resolution: {integrity: sha512-b/6HCg7pHzmBapr4FFZqzchZwyw9kfjCOuwKFJZp2Xn4ABCUHEbPfBfO3EPEO+mm1CjiN48/YcXG1IxHME7b7w==} + + '@lynx-js/web-worker-rpc@0.19.8': + resolution: {integrity: sha512-POkpPZQjU+a0V/LGWNkaqy+/E6B2WEDYnbh+0FtRNLXcNOBOcgvZNg0fo6VVbw3UInmTwO0v+2g/kMjotizwkg==} + + '@lynx-js/web-worker-rpc@0.7.1': + resolution: {integrity: sha512-5aKeVfElE+duqBFWiOaiBBZiC2B7aTDJI88jY7ROrikD5suftmANbMRme8YC46UmJBOo2a5SyLaoZcW54SjL5Q==} + + '@lynx-js/web-worker-runtime@0.19.8': + resolution: {integrity: sha512-lZHnEWQ0QrNIHRGUzYBS22d4ckwyUM+1Gee8D2pCYSAdyqr4aRaNJrUEp8sO8YTh91kohN3c09YQcgRZ8sIstQ==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.3 + + '@lynx-js/web-worker-runtime@0.7.1': + resolution: {integrity: sha512-zqxmqBb77E54VHTo92wMmenRyqIuM9mUON8F+sJKPq0eBa8F2GG4y/fewaXvFU+6pfQUMawGU8wsAUqEiTJwrA==} + peerDependencies: + '@lynx-js/lynx-core': 0.1.0 + '@lynx-js/webpack-dev-transport@0.2.0': resolution: {integrity: sha512-RSy02FSoMsavEn2wna4khSJwT2uGW4XeLduKH8UDT3aCsBNM/rXndUyG6PbwMcywXCeyb8UofkhaQDtJvNJklA==} engines: {node: '>=18'} @@ -2995,6 +3100,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5215,6 +5323,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -6030,6 +6141,10 @@ snapshots: dependencies: css-tree: 3.1.0 + '@lynx-js/lynx-core@0.1.3': {} + + '@lynx-js/offscreen-document@0.1.4': {} + '@lynx-js/qrcode-rsbuild-plugin@0.4.4': {} '@lynx-js/react-alias-rsbuild-plugin@0.12.7': {} @@ -6143,8 +6258,82 @@ snapshots: dependencies: '@lynx-js/react': 0.116.2(@lynx-js/types@3.6.0)(@types/react@18.3.28) + '@lynx-js/web-constants@0.19.8': + dependencies: + '@lynx-js/web-worker-rpc': 0.19.8 + + '@lynx-js/web-constants@0.7.1': + dependencies: + '@lynx-js/web-worker-rpc': 0.7.1 + + '@lynx-js/web-core@0.19.8(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.11.3(tslib@2.8.1))': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/offscreen-document': 0.1.4 + '@lynx-js/web-constants': 0.19.8 + '@lynx-js/web-elements': 0.11.3(tslib@2.8.1) + '@lynx-js/web-mainthread-apis': 0.19.8 + '@lynx-js/web-worker-rpc': 0.19.8 + '@lynx-js/web-worker-runtime': 0.19.8(@lynx-js/lynx-core@0.1.3) + + '@lynx-js/web-core@0.7.1(@lynx-js/lynx-core@0.1.3)(@lynx-js/web-elements@0.7.7(tslib@2.8.1))': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/web-constants': 0.7.1 + '@lynx-js/web-elements': 0.7.7(tslib@2.8.1) + '@lynx-js/web-worker-rpc': 0.7.1 + '@lynx-js/web-worker-runtime': 0.7.1(@lynx-js/lynx-core@0.1.3) + + '@lynx-js/web-elements-reactive@0.2.2': {} + + '@lynx-js/web-elements-template@0.7.7': {} + + '@lynx-js/web-elements@0.11.3(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@lynx-js/web-elements@0.7.7(tslib@2.8.1)': + dependencies: + '@lynx-js/web-elements-reactive': 0.2.2 + '@lynx-js/web-elements-template': 0.7.7 + tslib: 2.8.1 + + '@lynx-js/web-mainthread-apis@0.19.8': + dependencies: + '@lynx-js/web-constants': 0.19.8 + hyphenate-style-name: 1.1.0 + wasm-feature-detect: 1.8.0 + + '@lynx-js/web-mainthread-apis@0.7.1': + dependencies: + '@lynx-js/web-constants': 0.7.1 + '@lynx-js/web-style-transformer': 0.2.2 + css-tree: 3.1.0 + hyphenate-style-name: 1.1.0 + '@lynx-js/web-rsbuild-server-middleware@0.19.7': {} + '@lynx-js/web-style-transformer@0.2.2': {} + + '@lynx-js/web-worker-rpc@0.19.8': {} + + '@lynx-js/web-worker-rpc@0.7.1': {} + + '@lynx-js/web-worker-runtime@0.19.8(@lynx-js/lynx-core@0.1.3)': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/offscreen-document': 0.1.4 + '@lynx-js/web-constants': 0.19.8 + '@lynx-js/web-mainthread-apis': 0.19.8 + '@lynx-js/web-worker-rpc': 0.19.8 + + '@lynx-js/web-worker-runtime@0.7.1(@lynx-js/lynx-core@0.1.3)': + dependencies: + '@lynx-js/lynx-core': 0.1.3 + '@lynx-js/web-constants': 0.7.1 + '@lynx-js/web-mainthread-apis': 0.7.1 + '@lynx-js/web-worker-rpc': 0.7.1 + '@lynx-js/webpack-dev-transport@0.2.0': {} '@lynx-js/webpack-runtime-globals@0.0.6': {} @@ -8588,6 +8777,8 @@ snapshots: human-signals@2.1.0: {} + hyphenate-style-name@1.1.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -11537,6 +11728,8 @@ snapshots: dependencies: makeerror: 1.0.12 + wasm-feature-detect@1.8.0: {} + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 diff --git a/template/sparkling-app-template/app.config.ts b/template/sparkling-app-template/app.config.ts index b56f2e61..50f83ecc 100644 --- a/template/sparkling-app-template/app.config.ts +++ b/template/sparkling-app-template/app.config.ts @@ -12,9 +12,23 @@ const lynxConfig = defineConfig({ }, }, output: { - assetPrefix: 'asset:///', filename: { - bundle: '[name].lynx.bundle' + bundle: '[name].lynx.bundle', + }, + }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: { + output: { + assetPrefix: 'asset:///', + }, }, }, plugins: [ diff --git a/template/sparkling-app-template/package.json b/template/sparkling-app-template/package.json index b8ee8085..777390bc 100644 --- a/template/sparkling-app-template/package.json +++ b/template/sparkling-app-template/package.json @@ -11,6 +11,9 @@ "type": "module", "scripts": { "build": "sparkling-app-cli build --copy", + "build:web": "sparkling-app-cli build --platform web", + "dev:web": "sparkling-app-cli dev --platform web", + "run:web": "sparkling-app-cli run:web", "autolink": "sparkling-app-cli autolink", "test": "vitest run --passWithNoTests", "run:android": "sparkling-app-cli run:android", @@ -24,6 +27,9 @@ "@lynx-js/qrcode-rsbuild-plugin": "^0.4.4", "@lynx-js/react-rsbuild-plugin": "^0.12.7", "@lynx-js/rspeedy": "^0.13.3", + "@lynx-js/web-core": "^0.7.0", + "@lynx-js/web-elements": "^0.7.0", + "sparkling-web-shell": "~2.0.1", "@lynx-js/types": "^3.6.0", "@testing-library/jest-dom": "^6.9.1", "@types/react": "^18.3.20",