diff --git a/packages/methods/sparkling-media/package.json b/packages/methods/sparkling-media/package.json index c416f89b..4bf3b94f 100644 --- a/packages/methods/sparkling-media/package.json +++ b/packages/methods/sparkling-media/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-media/src/web/index.ts b/packages/methods/sparkling-media/src/web/index.ts new file mode 100644 index 00000000..2c644ea0 --- /dev/null +++ b/packages/methods/sparkling-media/src/web/index.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2025 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { registerWebMethod } from 'sparkling-method/web-registry'; +import type { WebMethodHandler } from 'sparkling-method'; + +registerWebMethod('media.chooseMedia', (params, callback) => { + const data = params.data as Record | null; + const mediaTypes = data?.mediaTypes as string[] | undefined; + const sourceType = data?.sourceType as string | undefined; + const maxCount = (data?.maxCount as number) ?? 1; + + // Build accept string from mediaTypes + const acceptParts: string[] = []; + if (mediaTypes?.includes('image')) acceptParts.push('image/*'); + if (mediaTypes?.includes('video')) acceptParts.push('video/*'); + const accept = acceptParts.length ? acceptParts.join(',') : 'image/*,video/*'; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = maxCount > 1; + + // Camera source: use capture attribute (mobile browsers only) + if (sourceType === 'camera') { + input.capture = (data?.cameraType as string) === 'front' ? 'user' : 'environment'; + } + + input.onchange = () => { + const files = Array.from(input.files ?? []); + if (!files.length) { + callback({ code: 0, msg: 'No file selected' }); + return; + } + + const results = files.slice(0, maxCount).map(file => ({ + tempFilePath: URL.createObjectURL(file), + size: file.size, + type: file.type, + name: file.name, + })); + + callback({ code: 1, msg: 'ok', data: results }); + }; + + // Handle user cancelling the file picker + input.addEventListener('cancel', () => { + callback({ code: 0, msg: 'User cancelled' }); + }); + + input.click(); +}); + +registerWebMethod('media.downloadFile', (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const extension = data?.extension as string | undefined; + const headers = data?.header as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + fetch(url, { headers }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then(blob => { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = `download.${extension || 'bin'}`; + a.click(); + URL.revokeObjectURL(objectUrl); + callback({ code: 1, msg: 'ok', data: { tempFilePath: objectUrl } }); + }) + .catch(e => { + callback({ code: 0, msg: `Download failed: ${e}` }); + }); +}); + +// Shared implementation for uploadFile and uploadImage (same API shape) +const uploadFileImpl: WebMethodHandler = (params, callback) => { + const data = params.data as Record | null; + const url = data?.url as string | undefined; + const filePath = data?.filePath as string | undefined; + const headers = data?.header as Record | undefined; + const extraParams = data?.params as Record | undefined; + + if (!url) { + callback({ code: 0, msg: 'url is required' }); + return; + } + + // filePath on web is a blob URL from chooseMedia + const filePromise = filePath + ? fetch(filePath).then(r => r.blob()) + : Promise.resolve(new Blob()); + + filePromise + .then(blob => { + const formData = new FormData(); + formData.append('file', blob); + if (extraParams && typeof extraParams === 'object') { + for (const [k, v] of Object.entries(extraParams)) { + formData.append(k, String(v)); + } + } + return fetch(url, { method: 'POST', headers, body: formData }); + }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then(json => callback({ code: 1, msg: 'ok', data: json })) + .catch(e => callback({ code: 0, msg: `Upload failed: ${e}` })); +}; + +registerWebMethod('media.uploadFile', uploadFileImpl); +registerWebMethod('media.uploadImage', uploadFileImpl); + +registerWebMethod('media.saveDataURL', (_params, callback) => { + // No web API to save directly to device photo album + callback({ code: 0, msg: 'media.saveDataURL is not supported on web' }); +}); diff --git a/packages/methods/sparkling-navigation/module.config.json b/packages/methods/sparkling-navigation/module.config.json index 3e892e58..a63028ef 100644 --- a/packages/methods/sparkling-navigation/module.config.json +++ b/packages/methods/sparkling-navigation/module.config.json @@ -1,6 +1,6 @@ { "name": "sparkling-navigation", - "platforms": ["android", "ios"], + "platforms": ["android", "ios", "web"], "version": "1.0.0", "description": "Router methods for navigation and page management in Sparkling apps", "methods": { diff --git a/packages/methods/sparkling-navigation/package.json b/packages/methods/sparkling-navigation/package.json index 2903bb18..8b3a55c2 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..b3e8a2aa --- /dev/null +++ b/packages/methods/sparkling-navigation/src/web/index.ts @@ -0,0 +1,88 @@ +// 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'; + +/** + * 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. + * `sparkling-web-shell`). Embedders that render Lynx cards inside a larger page + * (such as the go-web `` preview) can override this with `setRouterWebHost` + * so navigation stays scoped to the card instead of the top-level document. + */ +export interface RouterWebHost { + open(pageName: string, scheme: string): void; + close(): void; +} + +const defaultHost: RouterWebHost = { + open(pageName, scheme) { + const state = { page: pageName, scheme }; + window.history.pushState(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 } }), + ); + }, + close() { + window.history.back(); + }, +}; + +let host: RouterWebHost = defaultHost; + +/** + * Override how `router.open` / `router.close` navigate on web. Call with a + * host that navigates within an embedded card to avoid touching global + * `window.history`. + */ +export function setRouterWebHost(next: RouterWebHost): void { + host = next; +} + +/** Extract the target page name (no extension) from a router scheme. */ +function parsePageName(scheme: string): string | null { + const url = new URL(scheme); + const bundleParam = url.searchParams.get('bundle'); + const urlParam = url.searchParams.get('url'); + if (urlParam) { + // Dev mode: url param is a full URL, extract the basename. + const urlPath = new URL(urlParam).pathname; + return urlPath.replace(/^\//, '').replace(/\.lynx\.bundle$/, ''); + } + if (bundleParam) { + return bundleParam.replace(/\.lynx\.bundle$/, ''); + } + return null; +} + +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 pageName = parsePageName(scheme); + if (!pageName) { + callback({ code: 0, msg: 'No bundle or url param in scheme' }); + return; + } + host.open(pageName, scheme); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `Failed to parse scheme: ${e}` }); + } +}); + +registerWebMethod('router.close', (_params, callback) => { + try { + host.close(); + callback({ code: 1, msg: 'ok' }); + } catch (e) { + callback({ code: 0, msg: `Failed to close: ${e}` }); + } +}); diff --git a/packages/methods/sparkling-storage/package.json b/packages/methods/sparkling-storage/package.json index 9764c410..4fb6c63f 100644 --- a/packages/methods/sparkling-storage/package.json +++ b/packages/methods/sparkling-storage/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/src/web/index.d.ts", + "default": "./dist/src/web/index.js" + } + }, + "typesVersions": { + "*": { + "web": ["dist/src/web/index.d.ts"] + } + }, "files": [ "index.ts", "src", diff --git a/packages/methods/sparkling-storage/src/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/sparkling-method/package.json b/packages/sparkling-method/package.json index ac8ad67e..3ef70b37 100644 --- a/packages/sparkling-method/package.json +++ b/packages/sparkling-method/package.json @@ -9,6 +9,21 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./web-registry": { + "types": "./dist/web-registry.d.ts", + "default": "./dist/web-registry.js" + } + }, + "typesVersions": { + "*": { + "web-registry": ["dist/web-registry.d.ts"] + } + }, "files": [ "dist", "ios", diff --git a/packages/sparkling-method/src/index.ts b/packages/sparkling-method/src/index.ts index 8ed9498d..379fbddc 100644 --- a/packages/sparkling-method/src/index.ts +++ b/packages/sparkling-method/src/index.ts @@ -4,7 +4,8 @@ /// -import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback } from './types'; +import type { PipeResponse, PipeErrorResponse, PipeCallOptions, MethodMap, EventCallback, WebMethodHandler } from './types'; +import { getWebMethodHandler } from './web-registry'; export type { PipeResponse, @@ -12,6 +13,7 @@ export type { PipeCallOptions, MethodMap, EventCallback, + WebMethodHandler, }; /** @@ -91,6 +93,28 @@ const LynxPipe = { return; } + // Web handler dispatch — if a handler is registered, use it. + // On native the registry is empty (no /web imports), so this is a no-op. + // On web, @lynx-js/web-core provides a NativeModules stub without spkPipe, + // so we must dispatch here before the NativeModules checks below. + const webHandler = getWebMethodHandler(method); + if (webHandler) { + try { + webHandler( + { + containerID: getContainerID(), + protocolVersion: '1.0.0', + data: params ?? null, + }, + callback + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + callback(createErrorResponse(-5, `Web pipe call failed: ${errorMsg}`)); + } + return; + } + // Check if NativeModules is available if (typeof NativeModules === 'undefined') { callback(createErrorResponse(-2, 'NativeModules is not available. Ensure you are running in a lynx environment.')); diff --git a/packages/sparkling-method/src/types.ts b/packages/sparkling-method/src/types.ts index 08d3fd63..c7817da2 100644 --- a/packages/sparkling-method/src/types.ts +++ b/packages/sparkling-method/src/types.ts @@ -42,3 +42,13 @@ export type MethodMap = string | { * Pipe event callback */ export type EventCallback = (event: unknown) => void; + +/** + * Handler function for a web method implementation. + * Receives the same envelope that native modules receive and must + * invoke the callback with a `{ code, msg, data? }` response. + */ +export type WebMethodHandler = ( + params: { containerID: string; protocolVersion: string; data: unknown }, + callback: (response: { code: number; msg: string; data?: unknown }) => void, +) => void; diff --git a/packages/sparkling-method/src/web-registry.ts b/packages/sparkling-method/src/web-registry.ts new file mode 100644 index 00000000..1c15013c --- /dev/null +++ b/packages/sparkling-method/src/web-registry.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { WebMethodHandler } from './types'; + +export type { WebMethodHandler }; + +const registry = new Map(); + +/** + * Register a web implementation for a method. + * Called at import time by method package `/web` subpath exports. + */ +export function registerWebMethod(methodName: string, handler: WebMethodHandler): void { + if (!methodName || typeof methodName !== 'string') { + throw new Error('methodName must be a non-empty string'); + } + if (typeof handler !== 'function') { + throw new Error('handler must be a function'); + } + registry.set(methodName, handler); +} + +/** + * Look up a registered web handler by method name. + */ +export function getWebMethodHandler(methodName: string): WebMethodHandler | undefined { + return registry.get(methodName); +} + +/** + * Check whether a web handler is registered for the given method name. + */ +export function hasWebMethod(methodName: string): boolean { + return registry.has(methodName); +} + +/** + * Detect whether the current environment is a web browser + * (as opposed to the Lynx native runtime). + * + * Note: We check for `document` rather than the absence of `NativeModules` + * because `@lynx-js/web-core` defines a `NativeModules` stub in the browser. + * Native Lynx does not provide a `document` global. + */ +export function isWebEnvironment(): boolean { + return typeof document !== 'undefined'; +}