diff --git a/.github/workflows/check-lowcoder-sdk.yml b/.github/workflows/check-lowcoder-sdk.yml new file mode 100644 index 0000000000..0160777fec --- /dev/null +++ b/.github/workflows/check-lowcoder-sdk.yml @@ -0,0 +1,40 @@ +name: Check Lowcoder SDK + +on: + pull_request: + paths: + - 'client/**' + - '.github/workflows/*lowcoder-sdk.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: check-lowcoder-sdk-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + if: github.repository == 'lowcoder-org/lowcoder' + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.22.2 + cache: yarn + cache-dependency-path: client/yarn.lock + - name: Install dependencies + run: node .yarn/releases/yarn-3.6.4.cjs install --immutable + - name: Test browser SDK contract + run: node .yarn/releases/yarn-3.6.4.cjs workspace lowcoder-sdk-webpack-bundle test + - name: Build browser SDK and website + env: + NODE_OPTIONS: --max_old_space_size=6144 + run: node .yarn/releases/yarn-3.6.4.cjs workspace lowcoder-sdk-webpack-bundle build + - name: Check release sizes and files + run: node packages/lowcoder-sdk-webpack-bundle/scripts/check-dist.cjs diff --git a/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml b/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml deleted file mode 100644 index 13f64a6742..0000000000 --- a/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Builds client/packages/lowcoder-sdk-webpack-bundle and deploys its dist/ folder to Netlify. -# -# Deploy uses --no-build so Netlify CLI does not run the site UI "build command" (e.g. expo). -# The webpack bundle is built in the prior CI step. -# -# Repository secrets (Netlify: Site settings → General → Site details → Site ID; -# User settings → Applications → Personal access tokens): -# NETLIFY_AUTH_TOKEN — Netlify personal access token -# NETLIFY_SITE_ID — Site API ID for the Netlify site - -name: Deploy SDK Webpack Bundle to Netlify - -on: - push: - branches: - - main - -permissions: - contents: read - -concurrency: - group: deploy-sdk-webpack-netlify-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: 20.x - cache: yarn - cache-dependency-path: client/yarn.lock - - - name: Install dependencies - uses: borales/actions-yarn@v4.2.0 - with: - cmd: install - dir: client - - - name: Build lowcoder-sdk-webpack-bundle - uses: borales/actions-yarn@v4.2.0 - with: - cmd: workspace lowcoder-sdk-webpack-bundle build - dir: client - - - name: Deploy dist to Netlify - working-directory: client/packages/lowcoder-sdk-webpack-bundle - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SDK_SITE_ID }} - run: npx --yes netlify-cli deploy --prod --dir=dist --no-build diff --git a/.github/workflows/release-lowcoder-sdk.yml b/.github/workflows/release-lowcoder-sdk.yml new file mode 100644 index 0000000000..d72797c37a --- /dev/null +++ b/.github/workflows/release-lowcoder-sdk.yml @@ -0,0 +1,45 @@ +name: Release Lowcoder SDK + +on: + release: + types: [released] + +permissions: + contents: read + +concurrency: + group: release-lowcoder-sdk + cancel-in-progress: false + +jobs: + publish: + if: github.repository == 'lowcoder-org/lowcoder' && !github.event.release.prerelease && !github.event.release.draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + - uses: actions/setup-node@v4 + with: + node-version: 22.22.2 + - name: Verify this stable release is the current main commit + run: node client/packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs + - name: Request the Cloudflare production build + env: + LOWCODER_SDK_DEPLOY_HOOK: ${{ secrets.LOWCODER_SDK_DEPLOY_HOOK }} + run: | + node <<'NODE' + const hook = process.env.LOWCODER_SDK_DEPLOY_HOOK; + if (!hook) throw new Error('Configure the LOWCODER_SDK_DEPLOY_HOOK repository secret.'); + const url = new URL(hook); + if (url.origin !== 'https://api.cloudflare.com' || !url.pathname.startsWith('/client/v4/workers/builds/deploy_hooks/')) { + throw new Error('Unexpected Cloudflare deploy hook URL.'); + } + (async () => { + const response = await fetch(url, { method: 'POST', signal: AbortSignal.timeout(30000) }); + const result = await response.json(); + if (!response.ok || !result.success || !result.result?.build_uuid) throw new Error('Cloudflare did not accept the build request.'); + console.log('Cloudflare build accepted: ' + result.result.build_uuid); + console.log('Publication is confirmed by the Cloudflare build, not by this trigger request.'); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + NODE diff --git a/client/packages/lowcoder-sdk-webpack-bundle/README.md b/client/packages/lowcoder-sdk-webpack-bundle/README.md index 0b465a65a4..7911e62d31 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/README.md +++ b/client/packages/lowcoder-sdk-webpack-bundle/README.md @@ -1,9 +1,133 @@ -# lowcoder-sdk-webpack-bundle -The code to use Lowcoder Apps on any Web Project +# Lowcoder browser SDK and playground -STEPS to use +The public pages share the Lowcoder website's Ubuntu typography, plum and purple +palette, gradient buttons, floating navigation, logo and hero artwork. Original +assets from the `lowcoder-cloud` website are served locally from `site/brand/`; +the pages do not depend on an external font service. Keep these assets and the +brand tokens in `site/site.css` aligned when the marketing website changes. -1) build the project with `npm run build` -2) host `dist` folder on server -3) add `bundle.js` as an external script on the HTML page where you want to include the app -4) Add the apps as given in demo under `/dist/index_test.html` +This workspace builds the native DOM embedding SDK and the public site at +https://sdk.lowcoder.cloud. The npm SDK lives alongside it in `../lowcoder-sdk`. + +## Build and verify + +From `client/`, with Node 22 and the repository's Yarn version: + +```sh +node .yarn/releases/yarn-3.6.4.cjs install --immutable +node .yarn/releases/yarn-3.6.4.cjs workspace lowcoder-sdk-webpack-bundle test +node .yarn/releases/yarn-3.6.4.cjs workspace lowcoder-sdk-webpack-bundle build +node .yarn/releases/yarn-3.6.4.cjs workspace lowcoder-sdk-webpack-bundle check:dist +``` + +`index.html` and `site/` are lightweight static pages. The playground loads the SDK +only on request and mounts `site/demo.json` through the real SDK. It exchanges +module inputs, outputs and events, and demonstrates unmount/remount. + +## Browser integration + +```html +
+ + +``` + +`ready` covers SDK initialization. Mount resolves after app data is loaded and +rendering is scheduled, not after every query/component finishes. One backend per +host page is supported: SDK config, Redux, history, styles and plugin globals are +shared. CSS is not sandboxed. Configure backend access/authentication separately. +The full embedding contract is maintained in `site/reference.html`; integration +recipes live in `site/recipes.html`. The documentation test checks every public +instance member, option, event and React prop against the source, and validates +anchors, snippet syntax and search behavior. + +Existing `.lowcoder-module-container` markup and callbacks still work; call +`LowcoderSDK.scan()` for later markup. See `/guide.html` for the complete example. + +## Chunk boundaries + +Webpack partitions its dependency graph, with one runtime and one set of React, +styled-components and plugin globals. The loader resolves URLs from its own script, +fetches chunks concurrently, evaluates in dependency order, propagates CSP nonces, +and exposes explicit failure/readiness. Content-hashed assets have immutable cache +headers; `bundle.js` and manifests must revalidate. Never split generated JS bytes. + +The SDK still includes component authoring/editor dependencies. These changes make +transfer/caching more granular; they do not claim a small independent renderer. +`check:dist` measures the emitted initial scripts and enforces release size limits. + +## Cloudflare deployment + +`wrangler.jsonc` deploys the static `dist/` folder to the `lowcoder-sdk` Worker. +The GitHub workflow tests and builds pull requests. Cloudflare Builds handles +production builds through its existing GitHub connection and build token. +The release policy is **stable releases from main**, not every main push: + +- Repository: `lowcoder-org/lowcoder`; production branch: `main`. +- Root: `client`; Node version: `22.22.2`. +- Build variables: `NODE_VERSION=22.22.2`, `SKIP_DEPENDENCY_INSTALL=true`. +- Build: `node packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs && node packages/lowcoder-sdk-webpack-bundle/scripts/build-release.cjs`. +- Deploy: `node packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs && npx --yes wrangler@4.129.0 deploy --config packages/lowcoder-sdk-webpack-bundle/wrangler.jsonc`. +- Disable preview builds: this production config owns the public SDK route. + +Keep push-triggered production builds disabled. Create a Cloudflare Deploy Hook +for this Worker's `main` branch and store its URL in the GitHub repository secret +`LOWCODER_SDK_DEPLOY_HOOK`. `.github/workflows/release-lowcoder-sdk.yml` requests +that build only for a published stable release. Treat the hook URL as a credential; +it allows its holder to trigger this Worker's builds. Never put it in source. + +The release guard runs before the request, before building and before deploying. +It requires the checked-out commit, current `main`, and the latest stable release +tag to resolve to the same commit. A pre-release, moved main branch, older release +or unavailable GitHub verification fails closed. Publish the stable release while +main still points at that commit. The guard is deliberately stricter than ancestry. + +Activation requires these files on `main` and the deploy-hook secret. The GitHub +job confirms that Cloudflare accepted the request; check Cloudflare's build result +and the live domain to confirm publication. No npm package is published here. + +Setup status (2026-10-11): the `sdk-stable-release-main` hook exists for `main`, +and its URL is stored as `LOWCODER_SDK_DEPLOY_HOOK` in the repository's Actions +secrets. The guarded build and deploy commands above are saved in Cloudflare; +push-triggered production and preview builds remain disabled. This source must +be on `main` before a subsequent stable release can use the workflow. Creating +or merging these files does not itself deploy the SDK or publish a release. + +Cloudflare documents branch-head hooks at +https://developers.cloudflare.com/workers/ci-cd/builds/deploy-hooks/. + +Before publishing an update: + +```sh +node scripts/prepare-release.cjs https://sdk.lowcoder.cloud +node scripts/check-dist.cjs +npx wrangler deploy +``` + +The release step downloads the previous public asset manifest, checks SHA-256, +and retains old runtime files so cached loaders/open tabs survive deployments. +It deliberately fails if the previous release cannot be verified. The first +migration seeded this manifest from the verified Netlify deployment +`684309148dd28d5fbb335d1e` (870 public files; Netlify's deployment configuration is +not a served asset). Netlify remains available for rollback; no site deletion is +part of migration. Retained assets are not loaded by new visitors. + +For a standalone first preview, omit the previous origin. For production updates, +never omit asset retention. Cloudflare serves true 404 responses for missing files, +not the landing page as JavaScript. Keep custom-component sandbox files in place. + +Production uses the `sdk.lowcoder.cloud/*` Worker route and a proxied placeholder +DNS A record (`192.0.2.0`); the static Worker serves every path without an origin. +To roll back to Netlify, restore the proxied CNAME `sdk` → +`lowcoder-sdk.netlify.app`, then remove the SDK Worker route. Leave the Worker +assets available until caches and any new integrations have been considered. diff --git a/client/packages/lowcoder-sdk-webpack-bundle/index.html b/client/packages/lowcoder-sdk-webpack-bundle/index.html index 43d6604063..ba7fcb6304 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/index.html +++ b/client/packages/lowcoder-sdk-webpack-bundle/index.html @@ -1,37 +1,348 @@ - + - - - - YOUR APP TITLE - + + + Lowcoder SDK — Your product. Lowcoder inside. + + + + + + - - -

App 1

-
- - - -
-
+ + + + +
+
+
+
+
THE NATIVE EMBEDDING SDK
+

Your product.
Lowcoder inside.

+

+ Build the parts that make your product yours. Compose the rest + visually — and embed them directly into your app’s DOM. +

+ +
+
+
+ ● ● ●Your application +
+
+
+ +
+
+
Your customer workspace
+
+ Lowcoder + Built visually.
Connected natively.
+
+
+ InputsOutputsEvents +
+
+

Your design. Your navigation. Your product.

+
+
+
+
+
+ Native DOM renderingInputs, outputs & events + React or plain JavaScriptCloud or self-hosted +
+
+
+
+
+
+
01 / SEE THE CONNECTION
+

One page. Two ways to build.

+
+

+ The shell is ordinary HTML. The panel inside it is a real Lowcoder + module. Change the customer and watch the input travel. +

+
+
+
+ ● ● ●your-product.example / customersNative DOM +
+
+ +
+
+
+ CUSTOMER WORKSPACE +

A familiar home for new features.

+
+ +
+
+ LOWCODER MODULEMounted in this page +
+
+
+ </> +

Meet your next embedded feature.

+

+ Load the SDK and mount a customer panel here.
The + demo uses sample data; no account needed. +

+ +
+
+
+ Ready when you are. The SDK loads on demand. +
+
+
+
+
+ THE BRIDGEHost ↔ Lowcoder +
+
+Run the demo to see real module outputs and events.
+
+
+
+

+ Inspect #embed in your browser. The module renders as + DOM elements inside the host page. +

+ Download this demo’s app definition ↓ +
+
+
+
+
+
+
02 / BUILD WHERE IT MATTERS
+

A feature, without a second front end.

+
+

+ Keep your product’s shell in code. Give the workflows inside it a + visual home your team can maintain. +

+
+

+ CRM, website, ticketing & POS integration recipes ↗ +

+
+
+ 01 +

Customer portals

+

+ Bring account tools, forms and service workflows into the product + your customers already use. +

+ Pass the customer context → +
+
+ 02 +

Operational workspaces

+

+ Add approval screens, data tables and internal processes without + rebuilding the surrounding application. +

+ Return outputs and decisions → +
+
+ 03 +

Product extensions

+

+ Ship a visually maintained module alongside your existing UI, then + react to its events in your own code. +

+ Connect to host actions → +
+
+
+
+
+
03 / MAKE IT YOURS
+

A DOM element.
A few lines.
Your app.

+

+ Load the browser SDK once, wait for it to be ready, then mount your + published Lowcoder app. +

+ Read the integration guide ↗ +

+ Explore every function, option and event ↗ +

+
+ npm install lowcoder-sdkFor React projects +
+
+
+
+ index.html +
+
+<div id="my-app"></div>
+<script src="https://sdk.lowcoder.cloud/bundle.js"></script>
+<script>
+  LowcoderSDK.ready.then(async (sdk) => {
+    const app = await sdk.mount('#my-app', {
+      appId: 'YOUR_PUBLISHED_APP_ID',
+      moduleInputs: { customerId: 'customer-42' }
+    });
 
-    
-    
+    app.on('moduleOutputChange', (output) => {
+      console.log(output);
+    });
+  }).catch(console.error);
+</script>
+
+
+
+

Your UI, connected.

+

Start with one module. Keep ownership of the product around it.

+ Build your first module ↗ +
+
+ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/index.jsx b/client/packages/lowcoder-sdk-webpack-bundle/index.jsx index ceaef9c99e..00222aa695 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/index.jsx +++ b/client/packages/lowcoder-sdk-webpack-bundle/index.jsx @@ -1,62 +1,58 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import { LowcoderAppView } from "./src/index"; +import { bootstrapAppAt } from "./src/index"; -const opChangeDefault = (op) => {}; -const onModuleEventTriggeredDefault = (op) => {}; +const instances = new WeakMap(); +const api = window.LowcoderSDK = window.LowcoderSDK || {}; -for ( - var i = 0; - i < document.querySelectorAll(".lowcoder-module-container").length; - i++ -) { - const App = () => { - return ( - - ); - } +api.mount = async (target, options = {}) => { + const node = typeof target === "string" ? document.querySelector(target) : target; + if (!(node instanceof Element)) throw new Error("Provide an existing element to LowcoderSDK.mount."); + const { appId, ...settings } = options; + if (!appId) throw new Error("Provide an appId to LowcoderSDK.mount."); + const previous = instances.get(node); + const pending = (async () => { + if (previous) (await previous.catch(() => undefined))?.destroy(); + return bootstrapAppAt(appId, node, settings); + })(); + instances.set(node, pending); + try { return await pending; } + catch (error) { if (instances.get(node) === pending) instances.delete(node); throw error; } +}; - ReactDOM.render( - React.createElement(App, {}, null), - document - .querySelectorAll(".lowcoder-module-container") - [i]?.querySelector(".lowcoder-module-display") - ); -} +api.unmount = async (target) => { + const node = typeof target === "string" ? document.querySelector(target) : target; + const pending = node && instances.get(node); + if (!pending) return; + (await pending)?.destroy(); + if (instances.get(node) === pending) instances.delete(node); +}; + +api.scan = async (root = document) => Promise.all( + Array.from(root.querySelectorAll(".lowcoder-module-container")).map(async (container) => { + const node = container.querySelector(".lowcoder-module-display"); + if (!node || instances.has(node)) return; + const value = (name) => container.querySelector(`.${name}`)?.value; + try { + const instance = await api.mount(node, { + appId: value("module-id"), + moduleInputs: JSON.parse(value("module-input") || "{}"), + baseUrl: value("locoder-backend-url") || "https://api-service.lowcoder.cloud", + webUrl: value("locoder-frontend-url") || "https://app.lowcoder.cloud", + orgId: value("locoder-org-id") || undefined, + }); + instance.on("moduleOutputChange", (output) => { + window.opChange?.(output); + container.dispatchEvent(new CustomEvent("lowcoder:output", { detail: output })); + }); + instance.on("moduleEventTriggered", (event) => { + window.onModuleEventTriggered?.(event); + container.dispatchEvent(new CustomEvent("lowcoder:event", { detail: event })); + }); + } catch (error) { + container.dispatchEvent(new CustomEvent("lowcoder:error", { detail: error })); + console.error(error); + } + }) +); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => api.scan(), { once: true }); +} else { api.scan(); } diff --git a/client/packages/lowcoder-sdk-webpack-bundle/package.json b/client/packages/lowcoder-sdk-webpack-bundle/package.json index e5978dfb91..2351668cf8 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/package.json +++ b/client/packages/lowcoder-sdk-webpack-bundle/package.json @@ -4,9 +4,10 @@ "version": "3.0.0", "main": "index.jsx", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test test/*.test.cjs", "start": "webpack-dev-server --config ./webpack.config.cjs --mode development --progress --color --port 5000", - "build": "webpack --mode production" + "build": "webpack --mode production", + "check:dist": "node scripts/check-dist.cjs" }, "author": "Lowcoder Software LTD", "license": "AGPL3", diff --git a/client/packages/lowcoder-sdk-webpack-bundle/plugins/JSEntryWebpackPlugin.cjs b/client/packages/lowcoder-sdk-webpack-bundle/plugins/JSEntryWebpackPlugin.cjs index 9182f332f4..436498c74b 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/plugins/JSEntryWebpackPlugin.cjs +++ b/client/packages/lowcoder-sdk-webpack-bundle/plugins/JSEntryWebpackPlugin.cjs @@ -1,200 +1,25 @@ -const path = require('path') +const fs = require("node:fs"); +const path = require("node:path"); +// Keep bundle.js stable; every file it loads is content-addressed. class JSEntryWebpackPlugin { - constructor(options = {}) { - this.options = { - filename: 'index.js', - template: 'auto', - publicPath: options.publicPath === undefined ? 'auto' : options.publicPath, - ...options, - } - } - apply(compiler) { - compiler.hooks.emit.tap('InjectCssEntry', async compilation => { - // console.log(Object.keys(compilation.assets)) - - /** output filenames for the given entry names */ - const entryNames = Object.keys(compiler.options.entry) - const outputFileNames = new Set( - (entryNames.length ? entryNames : ['main']).map(entryName => - // Replace '[name]' with entry name - this.options.filename.replace(/\[name\]/g, entryName), - ), - ) - - /** Option for every entry point */ - const entryOption = Array.from(outputFileNames).map(filename => ({ - ...this.options, - filename, - }))[0] - - /** The public path used inside the html file */ - const publicPath = this.getPublicPath( - compilation, - entryOption.filename, - entryOption.publicPath, - ) - - /** build output path */ - const templatePath = this.getTemplatePath(entryOption.template, compilation.options) - - /** Generated file paths from the entry point names */ - const assets = this.htmlWebpackPluginAssets( - compilation, - // 只处理一个 - Array.from(compilation.entrypoints.keys()).slice(0, 1), - publicPath, - templatePath, - ) - - // js entry - if (!compilation.assets[assets.entry]) return - // const { libsImportCode } = await import("../src/dev-utils/external.js"); - // const libs = libsImportCode(["lowcoder-sdk"]); - // ${libsImportCode(["lowcoder-sdk"])} - let content = `(function() { - - // adding entry points to single bundle.js file - let scripts = ${JSON.stringify(assets.js)}; - for (let i = 0; i < scripts.length; i++) { - const scriptEle = document.createElement('script'); - scriptEle.src = scripts[i]; - document.body.appendChild(scriptEle); + const { Compilation, sources } = compiler.webpack; + compiler.hooks.thisCompilation.tap("SDKLoader", (compilation) => { + compilation.hooks.processAssets.tap( + { name: "SDKLoader", stage: Compilation.PROCESS_ASSETS_STAGE_REPORT }, + () => { + const files = compilation.entrypoints.get("main").getFiles().filter((file) => file.endsWith(".js")); + const template = fs.readFileSync(path.join(__dirname, "../src/loader.js"), "utf8"); + compilation.emitAsset("bundle.js", new sources.RawSource(template.replace("__SDK_FILES__", JSON.stringify(files)))); + compilation.emitAsset("sdk-manifest.json", new sources.RawSource(JSON.stringify({ + version: require("../package.json").version, + entry: "bundle.js", + files: files.map((file) => ({ file, bytes: compilation.getAsset(file).source.size() })), + }, null, 2))); } - })()`; - - compilation.assets[entryOption.filename] = { - source() { - return content - }, - size() { - return content.length - }, - } - }) - } - - htmlWebpackPluginAssets(compilation, entryNames, publicPath, templatePath) { - // https://github.com/jantimon/html-webpack-plugin/blob/main/index.js#L640 - const assets = { - publicPath, - templatePath, - entry: '', - js: [], - css: [], - } - - // Extract paths to .js, .mjs and .css files from the current compilation - const entryPointPublicPathMap = {} - const extensionRegexp = /\.(css|js)(\?|$)/ - for (let i = 0; i < entryNames.length; i++) { - const entryName = entryNames[i] - /** entryPointUnfilteredFiles - also includes hot module update files */ - const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName).getFiles() - - const entryPointFiles = entryPointUnfilteredFiles.filter(chunkFile => { - // compilation.getAsset was introduced in webpack 4.4.0 - // once the support pre webpack 4.4.0 is dropped please - // remove the following guard: - const asset = compilation.getAsset && compilation.getAsset(chunkFile) - if (!asset) { - return true - } - // Prevent hot-module files from being included: - const assetMetaInformation = asset.info || {} - return !(assetMetaInformation.hotModuleReplacement || assetMetaInformation.development) - }) - - // Prepend the publicPath and append the hash depending on the - // webpack.output.publicPath and hashOptions - // E.g. bundle.js -> /bundle.js?hash - const entryPointPublicPaths = entryPointFiles.map(chunkFile => { - const urlPath = this.urlencodePath(chunkFile) - const entryPointPublicPath = publicPath + urlPath - - if (chunkFile.endsWith('.js')) { - assets.entry = urlPath - } - return entryPointPublicPath - }) - - entryPointPublicPaths.forEach(entryPointPublicPath => { - const extMatch = extensionRegexp.exec(entryPointPublicPath) - // Skip if the public path is not a .css, .mjs or .js file - if (!extMatch) { - return - } - // Skip if this file is already known - // (e.g. because of common chunk optimizations) - if (entryPointPublicPathMap[entryPointPublicPath]) { - return - } - entryPointPublicPathMap[entryPointPublicPath] = true - const ext = extMatch[1] - assets[ext].push(entryPointPublicPath) - }) - } - return assets - } - - getPublicPath(compilation, outputName, customPublicPath) { - const compilationHash = compilation.hash - - /** - * @type {string} the configured public path to the asset root - * if a path publicPath is set in the current webpack config use it otherwise - * fallback to a relative path - */ - const webpackPublicPath = compilation.getAssetPath(compilation.outputOptions.publicPath, { - hash: compilationHash, - }) - - // Webpack 5 introduced "auto" as default value - const isPublicPathDefined = webpackPublicPath !== 'auto' - - let publicPath = - // If the html-webpack-plugin options contain a custom public path uset it - customPublicPath !== 'auto' - ? customPublicPath - : isPublicPathDefined - ? // If a hard coded public path exists use it - webpackPublicPath - : // If no public path was set get a relative url path - path - .relative( - path.resolve(compilation.options.output.path, path.dirname(outputName)), - compilation.options.output.path, - ) - .split(path.sep) - .join('/') - - if (publicPath.length && publicPath.substr(-1, 1) !== '/') { - publicPath += '/' - } - - return publicPath - } - - getTemplatePath(template, options) { - const { context, output } = options - - return template === 'auto' - ? path.join(output.path, path.sep) - : path.join(context || '', template) - } - - urlencodePath(filePath) { - // some+path/demo.html?value=abc?def - const queryStringStart = filePath.indexOf('?') - const urlPath = queryStringStart === -1 ? filePath : filePath.substr(0, queryStringStart) - const queryString = filePath.substr(urlPath.length) - // Encode all parts except '/' which are not part of the querystring: - const encodedUrlPath = urlPath.split('/').map(encodeURIComponent).join('/') - return encodedUrlPath + queryString + ); + }); } } - -JSEntryWebpackPlugin.version = 1 - -module.exports = JSEntryWebpackPlugin +module.exports = JSEntryWebpackPlugin; diff --git a/client/packages/lowcoder-sdk-webpack-bundle/scripts/build-release.cjs b/client/packages/lowcoder-sdk-webpack-bundle/scripts/build-release.cjs new file mode 100644 index 0000000000..34a5a0a8bf --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/scripts/build-release.cjs @@ -0,0 +1,16 @@ +// Cloudflare Builds entrypoint, run from client/. Keep the tested build and +// asset-retention steps identical for manual and Git-connected releases. +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const client = path.resolve(__dirname, '../../..'); +const run = (args) => execFileSync(process.execPath, args, { + cwd: client, + stdio: 'inherit', + env: { ...process.env, NODE_OPTIONS: '--max_old_space_size=6144' }, +}); +const yarn = '.yarn/releases/yarn-3.6.4.cjs'; +run([yarn, 'install', '--immutable']); +run([yarn, 'workspace', 'lowcoder-sdk-webpack-bundle', 'test']); +run([yarn, 'workspace', 'lowcoder-sdk-webpack-bundle', 'build']); +run(['packages/lowcoder-sdk-webpack-bundle/scripts/prepare-release.cjs', 'https://sdk.lowcoder.cloud']); +run(['packages/lowcoder-sdk-webpack-bundle/scripts/check-dist.cjs']); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-dist.cjs b/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-dist.cjs new file mode 100644 index 0000000000..deb3911daa --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-dist.cjs @@ -0,0 +1,18 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const assert = require('node:assert/strict'); +const root = path.resolve(__dirname, '../dist'); +const manifest = JSON.parse(fs.readFileSync(path.join(root, 'sdk-manifest.json'))); +let raw = 0, gzip = 0, largest = 0; +for (const {file} of manifest.files) { + assert.match(file, /^assets\/[\w.-]+\.[a-f0-9]{12}\.js$/); + const data = fs.readFileSync(path.join(root,file)); + raw += data.length; gzip += zlib.gzipSync(data).length; largest = Math.max(largest,data.length); +} +assert.ok(largest < 2 * 1024 * 1024, `Initial chunk exceeds 2 MiB: ${largest}`); +assert.ok(raw < 16 * 1024 * 1024, `Initial SDK exceeds 16 MiB: ${raw}`); +for (const file of ['bundle.js','index.html','guide.html','reference.html','reference.css','reference.js','recipes.html','demo.json','site.css','playground.js','_headers','404.html','custom_component.html']) assert.ok(fs.existsSync(path.join(root,file)),file); +const files = fs.readdirSync(root,{recursive:true}).filter(f=>fs.statSync(path.join(root,f)).isFile()); +for (const file of files) assert.ok(fs.statSync(path.join(root,file)).size < 25 * 1024 * 1024, `Cloudflare size limit: ${file}`); +console.log(JSON.stringify({initialRequests:manifest.files.length,initialBytes:raw,initialGzipBytes:gzip,largestInitialChunkBytes:largest,totalFiles:files.length},null,2)); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs b/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs new file mode 100644 index 0000000000..f546b421b2 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/scripts/check-stable-release.cjs @@ -0,0 +1,40 @@ +// Deploy Hooks build a branch head, not a release tag. Fail closed unless that +// exact head is the current stable release and is still the head of main. +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +function validateRelease(release, head, main, tagCommit) { + assert.equal(release.draft, false, 'Drafts must not deploy'); + assert.equal(release.prerelease, false, 'Pre-releases must not deploy'); + assert.match(release.tag_name, /^v?\d+\.\d+\.\d+$/, 'Expected a stable version tag'); + assert.equal(head, main, 'Only the current main commit may deploy'); + assert.equal(head, tagCommit, 'Main must point to the exact stable release commit'); +} + +async function checkStableRelease() { + const response = await fetch('https://api.github.com/repos/lowcoder-org/lowcoder/releases/latest', { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'lowcoder-sdk-release' }, + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) throw new Error(`Cannot verify stable release: HTTP ${response.status}`); + const release = await response.json(); + // Validate before using the tag in Git arguments. + assert.match(release.tag_name, /^v?\d+\.\d+\.\d+$/, 'Expected a stable version tag'); + const git = args => execFileSync('git', args, { + cwd: path.resolve(__dirname, '../../../..'), encoding: 'utf8', timeout: 60000, + }).trim(); + const head = git(['rev-parse', 'HEAD']); + // Read both refs together; support lightweight and annotated release tags. + const refs = git(['ls-remote', 'origin', 'refs/heads/main', `refs/tags/${release.tag_name}`, `refs/tags/${release.tag_name}^{}`]).split('\n'); + const main = (refs.find(line => line.endsWith('refs/heads/main')) || '').split(/\s/)[0]; + const tagLines = refs.filter(line => line.includes('refs/tags/')); + const tagRef = tagLines.find(line => line.endsWith('^{}')) || tagLines[0]; + const tagCommit = tagRef.split(/\s/)[0]; + validateRelease(release, head, main, tagCommit); + console.log(`Verified stable release ${release.tag_name} at ${head}`); + return release; +} + +module.exports = { validateRelease, checkStableRelease }; +if (require.main === module) checkStableRelease().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/scripts/prepare-release.cjs b/client/packages/lowcoder-sdk-webpack-bundle/scripts/prepare-release.cjs new file mode 100644 index 0000000000..da87861637 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/scripts/prepare-release.cjs @@ -0,0 +1,43 @@ +// Preserve versioned files used by open tabs and the pre-migration browser loader. +// Run after building, before publishing. Never replace a freshly built file. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const root = path.resolve(__dirname, '../dist'); +const sha = data => crypto.createHash('sha256').update(data).digest('hex'); +const isRuntimeAsset = file => /^assets\/[\w.-]+$/.test(file) || /^[\w.-]+\.bundle\.js(?:\.LICENSE\.txt)?$/.test(file) || /^[a-f\d]+\.(?:woff2?|ttf|otf)$/.test(file); +async function main() { + const previous = process.argv[2]; + if (previous) { + const origin = new URL(previous); + if (origin.protocol !== 'https:') throw new Error('The previous release must use HTTPS.'); + const response = await fetch(new URL('/sdk-assets.json', origin)); + if (!response.ok) throw new Error(`Cannot retain previous release: HTTP ${response.status}`); + const manifest = await response.json(); + const queue = manifest.files; + if (!Array.isArray(queue) || queue.length > 15000) throw new Error('Invalid asset manifest.'); + let cursor = 0; + await Promise.all(Array.from({length:8},async()=>{ + while(cursor < queue.length) { + const asset = queue[cursor++]; + if (!isRuntimeAsset(asset.file) || !/^[a-f\d]{64}$/.test(asset.sha256)) throw new Error('Invalid asset record.'); + const target = path.join(root,asset.file); + try { await fs.access(target); continue; } catch {} + const response = await fetch(new URL('/'+asset.file,origin)); + if (!response.ok) throw new Error(`Cannot retain ${asset.file}: HTTP ${response.status}`); + const data = Buffer.from(await response.arrayBuffer()); + if (sha(data) !== asset.sha256) throw new Error(`Hash mismatch: ${asset.file}`); + await fs.mkdir(path.dirname(target),{recursive:true}); await fs.writeFile(target,data); + } + })); + } + const files = []; + for (const file of (await fs.readdir(root,{recursive:true})).sort()) { + if (!isRuntimeAsset(file)) continue; + const data = await fs.readFile(path.join(root,file)); + files.push({file,bytes:data.length,sha256:sha(data)}); + } + await fs.writeFile(path.join(root,'sdk-assets.json'),JSON.stringify({files},null,2)+'\n'); + console.log(`Release contains ${files.length} verified runtime assets.`); +} +main().catch(error=>{console.error(error.message);process.exitCode=1}); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/404.html b/client/packages/lowcoder-sdk-webpack-bundle/site/404.html new file mode 100644 index 0000000000..6896648b5f --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/404.html @@ -0,0 +1,103 @@ + + + + + + Page not found — Lowcoder SDK + + + + + + + + + +
+
+
404 / PAGE NOT FOUND
+

Nothing mounted here.

+

This page or SDK asset does not exist.

+ Back to the SDK playground +
+
+ + + diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/_headers b/client/packages/lowcoder-sdk-webpack-bundle/site/_headers new file mode 100644 index 0000000000..7fe4e66ef9 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/_headers @@ -0,0 +1,16 @@ +/* + Access-Control-Allow-Origin: * + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + +/assets/* + Cache-Control: public, max-age=31536000, immutable + +/bundle.js + Cache-Control: public, max-age=0, must-revalidate + +/sdk-manifest.json + Cache-Control: public, max-age=0, must-revalidate + +/sdk-assets.json + Cache-Control: public, max-age=0, must-revalidate diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/favicon.svg b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/favicon.svg new file mode 100644 index 0000000000..802584a530 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/hero-background.jpeg b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/hero-background.jpeg new file mode 100644 index 0000000000..3943899d4d Binary files /dev/null and b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/hero-background.jpeg differ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/lowcoder.svg b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/lowcoder.svg new file mode 100644 index 0000000000..c928306306 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/lowcoder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-300.woff2 b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-300.woff2 new file mode 100644 index 0000000000..15cb23ab19 Binary files /dev/null and b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-300.woff2 differ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-400.woff2 b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-400.woff2 new file mode 100644 index 0000000000..2a81dd95f8 Binary files /dev/null and b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-400.woff2 differ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-500.woff2 b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-500.woff2 new file mode 100644 index 0000000000..0c8b97fbf5 Binary files /dev/null and b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-500.woff2 differ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-700.woff2 b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-700.woff2 new file mode 100644 index 0000000000..598bf6a6ed Binary files /dev/null and b/client/packages/lowcoder-sdk-webpack-bundle/site/brand/ubuntu-700.woff2 differ diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/demo.json b/client/packages/lowcoder-sdk-webpack-bundle/site/demo.json new file mode 100644 index 0000000000..ac1ab7c749 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/demo.json @@ -0,0 +1,182 @@ +{ + "settings": { + "themeId": "default", + "maxWidth": { + "input": 0, + "dropdown": "1920" + } + }, + "ui": { + "comp": { + "container": { + "layout": { + "intro": { + "w": 24, + "h": 9, + "x": 0, + "y": 0, + "i": "intro" + }, + "details": { + "w": 24, + "h": 8, + "x": 0, + "y": 9, + "i": "details" + }, + "action": { + "w": 12, + "h": 5, + "x": 0, + "y": 18, + "i": "action" + } + }, + "items": { + "intro": { + "name": "welcome", + "compType": "text", + "comp": { + "text": "### {{customerName.value}}\nCustomer workspace \u00b7 {{plan.value}} plan", + "type": "markdown", + "autoHeight": "auto" + } + }, + "details": { + "name": "details", + "compType": "text", + "comp": { + "text": "**{{seats.value}} seats** in this workspace.\n\nThis panel is rendered by Lowcoder. Change the customer above to update its inputs.", + "type": "markdown", + "autoHeight": "auto" + } + }, + "action": { + "name": "openCustomerButton", + "compType": "button", + "comp": { + "text": "Open customer \u2192", + "type": "", + "onEvent": [ + { + "name": "click", + "handler": { + "compType": "triggerModuleEvent", + "comp": { + "name": "openCustomer" + } + } + } + ] + } + } + } + }, + "containerRowCount": 0, + "events": { + "enableEventTestMessage": false, + "list": [ + { + "name": "openCustomer" + } + ] + }, + "containerSize": { + "width": 24, + "height": 28 + }, + "io": { + "inputs": [ + { + "name": "customerName", + "type": "string", + "defaultValue": { + "compType": "string", + "comp": "Morgan Lee" + } + }, + { + "name": "plan", + "type": "string", + "defaultValue": { + "compType": "string", + "comp": "Growth" + } + }, + { + "name": "seats", + "type": "number", + "defaultValue": { + "compType": "number", + "comp": "12" + } + } + ], + "outputs": [ + { + "name": "customer", + "value": "{{customerName.value}}" + }, + { + "name": "plan", + "value": "{{plan.value}}" + }, + { + "name": "seats", + "value": "{{seats.value}}" + } + ] + } + }, + "compType": "module" + }, + "refTree": { + "value": "" + }, + "hooks": [ + { + "compType": "urlParams", + "comp": {}, + "name": "url" + }, + { + "compType": "dayJsLib", + "comp": {}, + "name": "dayjs" + }, + { + "compType": "lodashJsLib", + "comp": {}, + "name": "_" + }, + { + "compType": "utils", + "comp": {}, + "name": "utils" + }, + { + "compType": "message", + "comp": {}, + "name": "message" + }, + { + "compType": "localStorage", + "comp": {}, + "name": "localStorage" + }, + { + "compType": "currentUser", + "comp": {}, + "name": "currentUser" + }, + { + "compType": "theme", + "comp": {}, + "name": "theme" + } + ], + "preload": { + "css": "", + "script": "" + } +} diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/guide.html b/client/packages/lowcoder-sdk-webpack-bundle/site/guide.html new file mode 100644 index 0000000000..b48fe5fa5b --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/guide.html @@ -0,0 +1,380 @@ + + + + + + Integration guide — Lowcoder SDK + + + + + + + + + + + +
+
+
FROM A VISUAL APP TO YOUR PRODUCT
+

Put Lowcoder
inside your app.

+

+ The SDK renders Lowcoder components directly in a DOM element. Your + host app controls where they live and how data moves between the two. +

+
+
+
+ +
+
+ Looking for every function, option, return value and event? Open the + complete embedding API reference or the + CRM, website, ticketing and POS recipes. +
+
+

Start with plain JavaScript

+

+ Publish an app or module in Lowcoder and copy its application ID. + Create a mount element with enough space for its layout, then load + the browser SDK once. +

+
+
+ HTML + JavaScript +
+
+<div id="customer-panel" style="min-height: 360px"></div>
+<script src="https://sdk.lowcoder.cloud/bundle.js"></script>
+<script>
+  async function start() {
+    const sdk = await LowcoderSDK.ready;
+    const app = await sdk.mount('#customer-panel', {
+      appId: 'YOUR_PUBLISHED_APP_ID',
+      baseUrl: 'https://api-service.lowcoder.cloud',
+      webUrl: 'https://app.lowcoder.cloud',
+      moduleInputs: { customerId: 'customer-42' }
+    });
+    app.on('moduleOutputChange', console.log);
+    app.on('moduleEventTriggered', console.log);
+  }
+  start().catch(console.error);
+</script>
+
+

+ LowcoderSDK.ready resolves when the SDK scripts have + loaded. mount() resolves after the application + definition is loaded and rendering is scheduled. Individual + components and queries may still be loading. +

+
+
+

Inside a React application

+

+ The npm package provides LowcoderAppView. Install it + with npm install lowcoder-sdk and import the stylesheet + once in your application. +

+
+
+ CustomerPanel.tsx +
+
+import { LowcoderAppView } from 'lowcoder-sdk';
+import 'lowcoder-sdk/dist/style.css';
+
+export function CustomerPanel({ customerId }) {
+  return (
+    <LowcoderAppView
+      appId="YOUR_PUBLISHED_APP_ID"
+      moduleInputs={{ customerId }}
+      onModuleOutputChange={(output) => {
+        console.log(output);
+      }}
+      onModuleEventTriggered={(eventName) => {
+        console.log(eventName);
+      }}
+    />
+  );
+}
+
+

+ Use the npm package for React integration or the browser loader for + a plain DOM integration. The published npm 3.0.0 package does not + yet include the hosted SDK’s readiness and disposal fixes, and its + appView subpath points to a missing file. Use the root import above; + check the + distribution table before + choosing lifecycle APIs. +

+
+
+

Make the connection work both ways

+

+ Define inputs, outputs, events and methods in a Lowcoder + module. Use those same names in your host + application. A normal app can be embedded too; the module interface + is what provides this explicit communication contract. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DirectionAPIUse it for
Host → moduleapp.setModuleInputs(values)Customer context, filters and other named inputs.
Module → hostapp.on('moduleOutputChange', handler)Receive the module’s current output object.
Module → hostapp.on('moduleEventTriggered', handler) + React to a named event such as openCustomer. +
Host → moduleapp.invokeMethod(name, args) + Call a method explicitly exposed by the module after it has + rendered. +
+
+
+
+app.setModuleInputs({ customerId: 'customer-99' });
+
+const unsubscribe = app.on('moduleEventTriggered', (name) => {
+  if (name === 'openCustomer') {
+    // Run your host application's navigation or action here.
+  }
+});
+
+// Each event has one active handler per instance.
+unsubscribe();
+
+
+
+

Mount, update, clean up

+

+ Keep the returned instance while the panel is visible. Update inputs + when context changes. Unmount it when your host removes that screen. +

+
+
+// Browser SDK
+await LowcoderSDK.unmount('#customer-panel');
+
+// Or dispose the instance directly.
+app.destroy();
+
+// Mounting again on the same element replaces its previous instance.
+const next = await LowcoderSDK.mount('#customer-panel', {
+  appId: 'ANOTHER_PUBLISHED_APP_ID'
+});
+
+

+ The browser SDK reports failed downloads through the rejected + ready promise and the + lowcoder:error window event. Handle mount errors in + your host UI so visitors see a useful retry message. +

+
+
+

Choose your backend. Check the boundary.

+

+ Set baseUrl to your Lowcoder API origin and + webUrl to your Lowcoder frontend. For + workspace-specific login, pass orgId. Hosting the SDK + assets elsewhere does not move your backend or change application + permissions. +

+
    +
  • + Public apps must be explicitly published and accessible to their + intended audience. +
  • +
  • + Private apps still require authentication. Configure your API’s + allowed origins and cookie settings for the host application; test + the actual production domains. +
  • +
  • + Module inputs are client-visible context, never an authorization + mechanism. Enforce access on the server. +
  • +
  • + The SDK shares runtime configuration and state. Use one Lowcoder + deployment per host page; independent backend isolation is not + supported. +
  • +
  • + Native embedding shares the page’s CSS and routing environment. + Test typography, overlays and navigation with your host + application. The SDK is not a style sandbox. +
  • +
+
+ The browser loader downloads content-hashed chunks from its own + location. Keep bundle.js and the complete emitted asset + directory together when self-hosting. Do not concatenate, rename or + selectively omit the chunks. +
+

+ The landing page does not load the SDK until you start the demo. + Splitting the runtime improves transfer parallelism and cache reuse; + it does not by itself remove the cost of rendering components. +

+
+
+

Already using bundle.js?

+

+ The existing .lowcoder-module-container markup + continues to work, including the historically spelled + locoder-backend-url and + locoder-frontend-url inputs. Global + opChange and + onModuleEventTriggered callbacks remain supported. +

+
+
+<div class="lowcoder-module-container">
+  <input type="hidden" class="module-id"
+    value="YOUR_PUBLISHED_APP_ID">
+  <input type="hidden" class="locoder-backend-url"
+    value="https://api-service.lowcoder.cloud">
+  <div class="lowcoder-module-display"></div>
+</div>
+<script src="https://sdk.lowcoder.cloud/bundle.js"></script>
+
+

+ For markup added after page load, call + await LowcoderSDK.ready followed by + LowcoderSDK.scan(). For new integrations, use the + explicit mount API so ownership and cleanup are clear. +

+

+ Legacy custom-component example ↗ + · Full API reference ↗ +

+
+
+
+ + + diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/playground.js b/client/packages/lowcoder-sdk-webpack-bundle/site/playground.js new file mode 100644 index 0000000000..741d6d1dae --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/playground.js @@ -0,0 +1,67 @@ +(() => { + const $ = (selector) => document.querySelector(selector); + const customers = { morgan: { customerName: 'Morgan Lee', plan: 'Growth', seats: 12 }, alex: { customerName: 'Alex Rivera', plan: 'Starter', seats: 0 } }; + let instance; + let active = false; + const entries = []; + const log = (label, data) => { + entries.unshift(`${label} ${typeof data === 'string' ? data : JSON.stringify(data)}`); + $('#event-log').textContent = entries.slice(0, 4).join('\n'); + }; + function loadSDK() { + if (window.LowcoderSDK) return window.LowcoderSDK.ready; + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = new URL('/bundle.js', location.href).href; + script.onload = () => window.LowcoderSDK.ready.then(resolve, reject); + script.onerror = () => { script.remove(); reject(new Error('The SDK could not be downloaded. Please try again.')); }; + document.head.appendChild(script); + }); + } + async function run() { + const button = $('#load-demo'); + button.disabled = true; + $('#status').textContent = 'Loading the SDK and demo module…'; + try { + const [sdk, dsl] = await Promise.all([loadSDK(), fetch('/demo.json').then((response) => { if (!response.ok) throw new Error('Demo definition unavailable.'); return response.json(); })]); + $('#embed').replaceChildren(); + instance = await sdk.mount('#embed', { appId: 'sdk-customer-demo', appDsl: dsl, moduleInputs: customers[$('#customer').value] }); + instance.on('moduleOutputChange', (output) => { log('LOWCODER → HOST', output); $('#status').textContent = 'Live module connected. Change the customer or click its button.'; }); + instance.on('moduleEventTriggered', (event) => { log('LOWCODER → HOST', event); $('#status').textContent = 'The host received the module event: ' + event; }); + log('HOST → LOWCODER', customers[$('#customer').value]); + active = true; + $('#reset-demo').hidden = false; + $('#status').textContent = 'SDK mounted. Waiting for the module to render…'; + } catch (error) { + $('#status').textContent = error.message; + log('LOAD ERROR', error.message); + $('#embed').replaceChildren(button); + button.disabled = false; + button.textContent = 'Reload and try again'; + button.removeEventListener('click', run); + button.addEventListener('click', () => location.reload(), { once: true }); + } + } + $('#load-demo')?.addEventListener('click', run); + $('#customer')?.addEventListener('change', () => { + if (!active) return; + const input = customers[$('#customer').value]; + instance.setModuleInputs(input); + log('HOST → LOWCODER', input); + }); + $('#reset-demo')?.addEventListener('click', async () => { + await window.LowcoderSDK.unmount('#embed'); + active = false; + $('#reset-demo').hidden = true; + const button = document.createElement('button'); + button.className = 'button primary'; button.id = 'load-demo'; button.textContent = 'Mount the module again'; + button.addEventListener('click', run); + $('#embed').replaceChildren(button); + $('#status').textContent = 'Module unmounted. The host page is still here.'; + log('HOST', 'Module destroyed; listeners and React root cleaned up.'); + }); + document.querySelectorAll('[data-copy]').forEach((button) => button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent); button.textContent = 'Copied'; } + catch { button.textContent = 'Select code to copy'; } + })); +})(); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/recipes.html b/client/packages/lowcoder-sdk-webpack-bundle/site/recipes.html new file mode 100644 index 0000000000..448467ab07 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/recipes.html @@ -0,0 +1,479 @@ + + + + + + Four ways to connect. — Lowcoder SDK + + + + + + + + + + + + +
+
+
YOUR PRODUCT, CONNECTED
+

Four ways to connect.

+

+ Practical module contracts and host code for CRM, websites, ticketing + and point of sale. +

+
+
+
+ +
+ +
+

Start with a module contract

+

+ These are integration recipes for your own host application, not + prebuilt connectors to a specific CRM, ticket vendor or payment + provider. Each names the module inputs, outputs and events you need + to create in Lowcoder first. Replace the placeholder application IDs + and host actions. +

+

+ Load bundle.js once as shown in the + quickstart, then use these examples + inside an async function or JavaScript module. They use the hosted + browser SDK. Remove each embed through + await sdk.unmount(element) when its screen closes. +

+
+ Named module events contain only their name. Use outputs for data; + an output update and a separate event are not an atomic message. For + business mutations, prefer a single structured output with a request + ID and deduplicate it in the host/backend. +
+
+
+

CRM: a customer workspace

+

+ Keep customer navigation and account selection in your CRM. Embed a + visually maintained account panel that receives the selected + customer and can ask the host to open their record. +

+
+ + + + + + + + + + + + + + + + + + + + + +
Module contractCreate in Lowcoder
Inputs + customerId (String), + customerName (String) +
Outputs + healthScore (Number) for the host’s summary +
Event + openCustomer triggered by a module button +
+
+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+let selected = { id: "customer-42", name: "Morgan Lee" };
+const app = await sdk.mount("#crm-panel", {
+  appId: "YOUR_CRM_MODULE_ID",
+  moduleInputs: { customerId: selected.id, customerName: selected.name }
+});
+app.on("moduleOutputChange", ({ healthScore }) => {
+  document.querySelector("#health-score").textContent = healthScore ?? "—";
+});
+app.on("moduleEventTriggered", (name) => {
+  if (name === "openCustomer") {
+    // Use the authoritative selection already held by the host.
+    location.assign("/customers/" + encodeURIComponent(selected.id));
+  }
+});
+function selectCustomer(customer) {
+  selected = customer;
+  app.setModuleInputs({ customerId: customer.id, customerName: customer.name });
+}
+
+

+ Create #crm-panel and #health-score in + your host. Wire its customer picker to selectCustomer. + The health output may arrive later than mount, so the host should + display a placeholder until it receives a value. +

+
+
+

Website: an interactive product configurator

+

+ Place a configuration module inside a product page. The site owns + the product route; Lowcoder owns the form, calculations and visual + editing. A named event can direct the host to its contact section. +

+
+ + + + + + + + + + + + + + + + + + + + + +
Module contractCreate in Lowcoder
Inputs + productId (String), + options (Array) +
Outputs + estimatedTotal (Number), calculated by the + module +
EventrequestContact triggered by the visitor
+
+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+const app = await sdk.mount("#configurator", {
+  appId: "YOUR_CONFIGURATOR_MODULE_ID",
+  moduleInputs: {
+    productId: "desk-01",
+    options: JSON.stringify([
+      { id: "oak", label: "Oak", price: 120 },
+      { id: "walnut", label: "Walnut", price: 160 }
+    ])
+  }
+});
+app.on("moduleOutputChange", ({ estimatedTotal }) => {
+  document.querySelector("#estimate").textContent =
+    typeof estimatedTotal === "number"
+      ? new Intl.NumberFormat("en", { style: "currency", currency: "EUR" })
+          .format(estimatedTotal)
+      : "Choose your options";
+});
+app.on("moduleEventTriggered", (name) => {
+  if (name === "requestContact") {
+    document.querySelector("#contact").scrollIntoView();
+  }
+});
+
+

+ Create the #configurator, #estimate and + #contact elements. Publish the module for the intended + audience. Treat the displayed estimate as client-side information; + calculate an authoritative price on your backend before accepting an + order. +

+
+
+

Ticket system: context and escalation

+

+ Embed a triage panel alongside a ticket. Send the ticket context as + a Data input. Return an escalation request as one structured output, + so its ticket ID, reason and request ID travel together. +

+
+ + + + + + + + + + + + + + + + + + + + + +
Module contractCreate in Lowcoder
Input + ticket (Data): + { id, subject, priority } +
Output + escalationRequest (Data): null, or + { requestId, ticketId, reason } +
Module action + On explicit user escalation, assign a fresh request ID and + set the complete output object +
+
+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+const ticket = { id: "T-1042", subject: "Export failed", priority: "normal" };
+const app = await sdk.mount("#ticket-tools", {
+  appId: "YOUR_TRIAGE_MODULE_ID",
+  moduleInputs: { ticket: JSON.stringify(ticket) }
+});
+const handled = new Set();
+app.on("moduleOutputChange", ({ escalationRequest: request }) => {
+  if (!request || typeof request.requestId !== "string" || !request.requestId ||
+      request.ticketId !== ticket.id || typeof request.reason !== "string" ||
+      handled.has(request.requestId)) return;
+  handled.add(request.requestId);
+  // Show a host-owned confirmation using safe text rendering.
+  // Its submit action calls your ticket API, which validates access
+  // and deduplicates requestId. This output is a request, not authorization.
+  document.querySelector("#escalation-reason").textContent = request.reason;
+  document.querySelector("#escalation-confirmation").hidden = false;
+});
+
+

+ Create #ticket-tools and a host confirmation panel + containing #escalation-reason. Initialize the module’s + request output to null and change it only on a + deliberate user action. Keep backend deduplication across remounts; + the in-memory set only covers this screen’s lifetime. +

+
+
+

POS: a loyalty panel beside the cart

+

+ Keep the cart, tender and payment flow in your POS. Embed a loyalty + or service panel that receives the current cart and requests an + optional lookup or host action. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Module contractCreate in Lowcoder
Inputs + cart (Array), customerId (String) +
EventrequestCustomerLookup
Method + refreshOffers, with a single + customerId parameter +
Output + offerStatus (String): module-owned + loading/success/failure information +
+
+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+const app = await sdk.mount("#loyalty-panel", {
+  appId: "YOUR_LOYALTY_MODULE_ID",
+  moduleInputs: {
+    customerId: "customer-42",
+    cart: JSON.stringify([{ sku: "coffee-01", quantity: 2 }])
+  }
+});
+app.on("moduleEventTriggered", (name) => {
+  if (name === "requestCustomerLookup") {
+    document.querySelector("#customer-lookup").showModal();
+  }
+});
+app.on("moduleOutputChange", ({ offerStatus }) => {
+  document.querySelector("#offer-status").textContent = offerStatus ?? "";
+});
+// Enable this host control only once the module is visibly usable.
+const refreshOffers = () => app.invokeMethod("refreshOffers", ["customer-42"]);
+document.querySelector("#refresh-offers").addEventListener("click", refreshOffers);
+
+// When leaving the POS screen, remove host-owned listeners too:
+async function closeLoyaltyPanel() {
+  document.querySelector("#refresh-offers").removeEventListener("click", refreshOffers);
+  await sdk.unmount("#loyalty-panel");
+}
+
+

+ Create the panel, a + <dialog id="customer-lookup">, status text and + refresh button. A method call is not awaitable and can do nothing + before rendering; a real POS should make loading and action + availability explicit. Your POS/backend remains responsible for + prices, discounts and payment state. The SDK does not provide a + payment-terminal or offline transaction API. +

+
+
+
+ + + diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/reference.css b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.css new file mode 100644 index 0000000000..732d54c151 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.css @@ -0,0 +1,151 @@ +.sdk-site .reference { + grid-template-columns: 230px minmax(0, 1fr); + gap: 54px; +} +.sdk-site .reference aside { + gap: 8px; + max-height: calc(100vh - 130px); +} +.sdk-site .reference aside .docs-back { + color: var(--purple-deep); + margin-bottom: 15px; +} +.sdk-site .search-label { + font-weight: 500; + font-size: 13px; +} +.sdk-site .search-box { + position: relative; +} +.sdk-site .search-box input { + width: 100%; + padding: 12px 34px 12px 12px; + font-size: 13px; +} +.sdk-site .search-box button { + position: absolute; + right: 5px; + top: 5px; + min-width: 32px; + min-height: 32px; + border: 0; + border-radius: 50%; + background: var(--lavender-soft); + font-size: 22px; + cursor: pointer; + color: var(--purple-deep); +} +.sdk-site .reference #search-status { + min-height: 18px; + margin: 0; + font-size: 11px; +} +.sdk-site #reference-nav { + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + padding: 4px 6px 4px 0; +} +.sdk-site #reference-nav a { + font-size: 13px; + text-decoration: none; + padding: 8px 10px; + border-radius: 8px; + overflow-wrap: anywhere; +} +.sdk-site #reference-nav a:hover { + background: var(--lavender-soft); + color: var(--purple-deep); +} +.sdk-site .reference h2 { + overflow-wrap: anywhere; + font-size: clamp(24px, 3vw, 31px); +} +.sdk-site .reference h3 { + font-size: 21px; + margin: 26px 0 12px; +} +.sdk-site .reference code { + overflow-wrap: anywhere; + font-size: 0.88em; +} +.sdk-site .reference .signature { + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--paper); + color: var(--purple-deep); +} +.sdk-site .reference pre code { + font-size: inherit; +} +.sdk-site .reference .code-card pre { + white-space: pre; + overflow-x: auto; + overflow-wrap: normal; + font-size: 12px; + line-height: 1.8; +} +.sdk-site .table-scroll td { + color: var(--muted); + line-height: 1.65; +} +.sdk-site .table-scroll td:first-child { + min-width: 150px; + color: var(--ink); +} +.sdk-site .release-label { + display: inline-block; + font-size: 11px; + border: 1px solid var(--line); + border-radius: 20px; + padding: 4px 12px; + margin-right: 6px; + color: var(--purple-deep); + background: var(--lavender-soft); +} +.sdk-site .reference #search-empty { + padding: 30px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--paper); +} +@media (max-width: 1000px) { + .sdk-site .reference { + grid-template-columns: 190px minmax(0, 1fr); + gap: 30px; + } +} +@media (max-width: 760px) { + .sdk-site .reference { + display: block; + padding-top: 30px; + } + .sdk-site .reference aside { + position: static; + display: block; + max-height: none; + margin-bottom: 30px; + } + .sdk-site .reference .docs-back { + display: inline-block; + } + .sdk-site .search-label { + display: block; + margin-bottom: 6px; + } + .sdk-site #reference-nav { + max-height: 185px; + border-top: 1px solid var(--line); + } + .sdk-site .reference .table-scroll td, + .sdk-site .reference .table-scroll th { + font-size: 12px; + padding: 12px 10px; + } + .sdk-site .reference .code-card pre { + font-size: 11px; + padding: 17px; + } +} diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/reference.html b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.html new file mode 100644 index 0000000000..6973fc2aff --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.html @@ -0,0 +1,1310 @@ + + + + + + The API, explained. — Lowcoder SDK + + + + + + + + + + + + +
+
+
THE NATIVE EMBEDDING CONTRACT
+

The API, explained.

+

+ Every embedding function, option and event: what it does, how to use + it, and what to expect. +

+
+
+
+ +
+ +
+

The embedding interface

+

+ A host owns the page and its navigation. Lowcoder owns the app + rendered inside one element. For communication, build a + module and define its inputs, outputs, events and + methods in the editor. Their names form your integration contract. +

+

+ A regular Lowcoder app can also be mounted, but these module APIs do + not give the host arbitrary access to its components, queries or + internal state. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Host needs to…Use
Load the browser runtime + LowcoderSDK.ready +
Create or remove an embed + mount() · + unmount() +
Send context into a module + setModuleInputs() +
Read values or react to an action + on() +
Run a module’s exposed action + invokeMethod() +
Render in React + LowcoderAppView +
Mount with the npm package + bootstrapAppAt() +
Use existing declarative markup + scan() +
+
+

+ Use the quickstart for your first embed or + jump to the + CRM, website, ticketing and POS recipes. + This reference covers the complete native embedding interface. + Component builders, editor controls, datasource plugin APIs and + re-exported utilities in the broader npm package are separate + extension APIs. +

+
+
+

Choose a distribution

+

+ Verified 11 October 2026 The + hosted browser SDK and npm package are released separately. This + page describes the current hosted SDK, with npm differences called + out below. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityHosted browser SDKnpm lowcoder-sdk@3.0.0
Entry pointhttps://sdk.lowcoder.cloud/bundle.jsimport … from "lowcoder-sdk"
Browser facade + LowcoderSDK.ready / mount / unmount / scan + + Not exported; use React or bootstrapAppAt +
Instance methods + on / setModuleInputs / invokeMethod / destroy + + on / setModuleInputs / invokeMethod; no + destroy +
Instance readiness + instance.ready; mount awaits definition loading + + No instance.ready; bootstrap resolves before + definition loading finishes +
Initial false / 0 / empty string inputsPreservedCan fall back to the module default
React lifecycle fixesPresent in current repository sourceNot yet in the published 3.0.0 package
Import subpathNot applicable + lowcoder-sdk/appView points to a missing file + in 3.0.0; use the root import +
+
+

+ The npm tarball was checked directly against the + published 3.0.0 metadata + and package contents. Its bundled declarations also omit + webUrl and orgId even though the runtime + accepts them. Do not infer installed behavior from the repository + version number alone. +

+

+ For the complete lifecycle documented here, use the hosted browser + interface. npm users should pin and test their installed version + before adopting newer source APIs. The npm build needs a bundler, + React and React DOM (declared peers: ≥18), and + import 'lowcoder-sdk/dist/style.css'. It is not a + standalone script-tag build. Import it only on the client in + server-rendered applications. +

+
+
+

LowcoderSDK.ready

+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+// sdk.mount, sdk.unmount and sdk.scan are now available.
+
+

+ Promise<LowcoderSDK> · Browser only +

+

+ Resolves after the initial SDK scripts have loaded and the mount API + exists. It does not wait for apps discovered by the automatic markup + scan, component rendering or queries. +

+

+ Rejects if an initial chunk fails to load or initialization does not + install the mount API. Catch the promise to show a retry message. A + repeated bundle.js tag reuses an existing loader and + its promise; it does not reset a failed load. After correcting a + network or policy failure, reload the page. +

+
+
+

LowcoderSDK.mount()

+
+
JavaScript
+
const sdk = await LowcoderSDK.ready;
+const app = await sdk.mount("#customer-panel", {
+  appId: "YOUR_PUBLISHED_MODULE_ID",
+  moduleInputs: { customerId: "customer-42" }
+});
+
+

+ mount(target: string | Element, options: MountOptions): + Promise<AppViewInstance> +

+
+ + + + + + + + + + + + + + + + + + + + + +
ParameterMeaning
target + An existing DOM element or CSS selector. A selector uses its + first match. +
options.appIdRequired application or module ID. Must be non-empty.
options + The shared options below, plus + appId. +
+
+

+ Creates a React root in the element and returns the instance after + definition loading and scheduling its render. Calling mount again on + an element managed by this facade waits for the previous + initialization and destroys its instance before replacing it. +

+

+ Rejects for a missing/invalid target, invalid selector, missing app + ID or an initialization failure. Failed initialization cleans up its + root. An initial API 401 instead renders the built-in + login prompt and resolves; resolution is not proof of authorization. + Later component or query errors are outside this promise. +

+

+ Give the container dimensions through your host CSS. The SDK has no + width, height, timeout or + AbortSignal mount options. +

+
+
+

LowcoderSDK.unmount()

+
+
JavaScript
+
const container = document.querySelector("#customer-panel");
+await LowcoderSDK.unmount(container);
+container.remove(); // The host owns removal of the element.
+
+

+ unmount(target: string | Element): Promise<void> +

+

+ Waits for a mount tracked by this browser facade, destroys the + instance and removes its tracking entry. The container stays in the + DOM. An unknown or already unmounted element is a no-op. A pending + initialization failure can reject this promise; an invalid CSS + selector can also reject. +

+

+ Unmount before removing the element, or keep and pass the element + reference. A selector cannot find a detached element. Instances + created directly with bootstrapAppAt are not tracked + here. +

+
+
+

Every initialization option

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionType / defaultBehavior
baseUrl + string · https://api-service.lowcoder.cloud + + Lowcoder backend base URL, without a trailing + /api. The SDK requests + /api/applications/{appId}/view beneath it. +
webUrlstring · https://app.lowcoder.cloudLowcoder frontend URL for the authentication flow.
orgIdstring · omitted + Workspace ID used to select the workspace login URL. Does + not grant access or set the authenticated identity. +
moduleInputsinput-name map · omitted + Initial values for declared inputs on a root module. See + input encoding and updates. +
appDslapplication definition object · omitted + Supply a Lowcoder application DSL instead of fetching the + initial definition. The hosted SDK clones it before applying + initial inputs. +
moduleDslMapmap of module ID → DSL · omitted + Definitions for nested modules when supplying + appDsl. Without appDsl, the server + response supplies and replaces this map. +
+
+

+ appId is additionally required by browser mount and + React, and is the first argument to bootstrapAppAt. Use + the actual ID of your published app; the local demo uses a synthetic + ID because it supplies its own complete definition. +

+

+ Supplying appDsl skips the initial app-definition + request and JavaScript datasource discovery. It does not promise + offline execution: queries, remote components, assets or missing + nested definitions may still use the network. Keep DSL compatible + with the runtime that loads it. +

+

+ There are no public token, headers, + fetch, theme, onReady or + onError initialization options. +

+
+
JavaScript
+
const app = await LowcoderSDK.mount("#module", {
+  appId: "YOUR_PUBLISHED_MODULE_ID",
+  baseUrl: "https://lowcoder.example.com",
+  webUrl: "https://lowcoder.example.com",
+  orgId: "YOUR_WORKSPACE_ID",
+  moduleInputs: { customerId: "customer-42" }
+});
+
+
+
+

instance.ready

+
+
JavaScript
+
await app.ready; // Already fulfilled when hosted sdk.mount() returns.
+
+

+ Promise<void> · Hosted/current source; absent in + npm 3.0.0 +

+

+ Waits for initial definition loading (and datasource discovery when + applicable), then schedules rendering. It rejects on initial loading + failures. It does not wait for a React commit, lazy + components, queries or callable module methods. +

+

+ There is no universal “everything rendered” event. If the host needs + business readiness, design a named output or event in the module, + such as dataLoaded. That name is your contract, not a + built-in SDK event. Event subscriptions do not replay previous + values; an early event can be missed. Do not use a one-time startup + event as your only readiness guarantee. +

+
+
+

instance.setModuleInputs()

+
+
JavaScript
+
app.setModuleInputs({
+  customerName: "Ada",          // String input
+  quantity: 0,                  // Number input
+  enabled: false,               // Boolean input
+  customer: JSON.stringify({ id: "customer-42" }), // Data input
+  rows: JSON.stringify([{ id: 1, total: 25 }])     // Array input
+});
+
+

+ setModuleInputs(inputs: InputMap): void +

+

+ Updates declared inputs on the mounted root module and schedules + rendering. Unknown input names are ignored. Omitted keys and + undefined leave existing module values unchanged; this + is not a reset or deep merge. Pass a fresh map for each update. +

+

+ The editor supports Data, String, Array, Number, Boolean and Query + input types. Values go through Lowcoder’s code controls: they are + source values converted to text, not an arbitrary JavaScript object + bridge. Serialize structured Data and Array values with + JSON.stringify. Raw objects become + [object Object]; arrays are not automatically + JSON-encoded. +

+

+ Plain strings, numbers and booleans work with the corresponding + scalar controls. The hosted SDK preserves 0, + false and ""; npm 3.0.0 can drop them at + initial load. JavaScript null is not a general “clear” + operation; for a Data input containing JSON null, pass + "null". +

+
+ Inputs can contain expressions. + {{ … }} is interpreted by Lowcoder’s template engine, + including inside serialized strings. JSON serialization does not + turn this into a guaranteed literal-value channel. Define bounded + input contracts and validate untrusted text. Inputs are + client-visible context, never authentication or permission checks. +
+

+ Query inputs represent Lowcoder query bindings. Passing a host + function does not create a supported callback bridge. Use module + events to request host actions. These update rules also apply to the + moduleInputs initialization option and React prop. +

+
+
+

instance.on()

+
+
JavaScript
+
let latestOutput;
+const stopOutputs = app.on("moduleOutputChange", (output) => {
+  latestOutput = output;
+  console.log("Current module outputs", output);
+});
+const stopEvents = app.on("moduleEventTriggered", (eventName) => {
+  console.log("Module event", eventName);
+});
+
+// When these subscriptions are no longer needed:
+stopOutputs();
+stopEvents();
+
+

+ on(event, handler?): () => void +

+
+ + + + + + + + + + + + + + + + + + + + +
EventHandler payloadWhen it fires
+ moduleOutputChange + (output: OutputMap) => void + The whole named output object when its values change by deep + equality. Not a patch. May initially be empty. +
+ moduleEventTriggered + (eventName: string) => void + The name of an event explicitly fired by the module. No + custom payload or DOM event object. +
+
+

+ Each instance has one handler per event type. + Registering another replaces the previous one. Fan out within your + own handler if several host features need notifications. The + returned function removes that subscription; there is no separate + off() method. +

+

+ In the hosted SDK, an old unsubscribe function cannot remove a newer + replacement handler. npm 3.0.0 does not have that safeguard. Calling + on(event, undefined) returns a no-op function; it does + not clear a previous handler. +

+

+ Subscriptions do not replay a current snapshot. Register immediately + after mounting, but do not assume the first output or startup event + cannot have occurred already. To exchange results, expose named + outputs. There is no atomic event-with-data payload or guaranteed + ordering between an event and a separate output update. +

+

+ Only the two names above are instance events. + lowcoder:ready and lowcoder:error belong + to the separate + browser DOM event interface. +

+
+
+

instance.invokeMethod()

+
+
JavaScript
+
// In the Lowcoder editor, define a method named "selectCustomer"
+// with parameters in this order: customerId, focusDetails.
+// Call after the user can interact with the rendered module.
+app.invokeMethod("selectCustomer", ["customer-42", true]);
+
+

+ invokeMethod(methodName: string, params: any[] = []): void +

+

+ Runs an action exposed as a named module method. Arguments are + positional, in the order configured in the editor. A missing, + undefined or null argument uses that + parameter’s default; 0, false and + "" are retained. Extra arguments are ignored. +

+

+ The name must identify a configured module method, not an arbitrary + component function or query. An unknown method, a normal app instead + of a module, or a call before the module root exists silently does + nothing. Calls are not queued. +

+
+ No return-value promise. The SDK discards the + internal action result. + await app.invokeMethod(...) does not wait for the + action to finish and does not catch its asynchronous errors. Model + completion, results and failures as module outputs/events. +
+

+ Use a request ID in your module contract when correlating + asynchronous work with host actions. For actions such as saving, + submitting or charging, perform authoritative validation and state + changes on your backend. +

+
+
+

instance.destroy()

+
+
JavaScript
+
app.destroy(); // Synchronous; safe to call more than once.
+
+

+ destroy(): void · Hosted/current source; absent in npm + 3.0.0 +

+

+ Clears instance handlers, releases the module reference and unmounts + its React root. It leaves the host element in place. Further input + updates and method calls have no effect. +

+

+ It does not cancel outstanding HTTP requests or undo shared SDK + configuration, global CSS or page-level state. For instances owned + by the browser facade, prefer + await LowcoderSDK.unmount(container), which also clears + that facade’s tracking entry. Do not manually destroy an instance + owned by a React wrapper that is managing its lifecycle. +

+
+
+

Instance & TypeScript types

+

+ AppViewInstance<I, O> represents an embedded app. + I describes its input-name map and O its + output object. Keep the instance returned by a factory; do not + construct a second React root on an element already owned by an + embed. +

+

+ The runtime class constructor is + new AppViewInstance(appId, node, root, options = {}), + where root is a React DOM Root. It is a + low-level implementation surface: use bootstrapAppAt or + browser mount so component registration, root creation + and initialization cleanup happen together. +

+
    +
  • + AppViewInstanceOptions<I>: the six + initialization options, with moduleInputs?: I. +
  • +
  • + LowcoderAppViewProps<I, O>: those options plus + appId, className and the two callbacks. +
  • +
  • + OutputChangeHandler<O> = (output: O) => void. +
  • +
  • + EventTriggerHandler = (eventName: string) => void. +
  • +
+

+ InputMap, OutputMap and MountOptions in this reference describe the + shapes of values; they are not named npm exports. Generic types do + not validate values at runtime. The browser facade is a script + global, not an npm export. Published npm 3.0.0 has incomplete + declaration routing as well as the missing members listed in the + distribution table; verify your toolchain instead of assuming + current source declarations are installed. +

+
+
+

bootstrapAppAt()

+
+
JavaScript
+
import { bootstrapAppAt } from "lowcoder-sdk";
+import "lowcoder-sdk/dist/style.css";
+
+const app = await bootstrapAppAt(
+  "YOUR_PUBLISHED_MODULE_ID",
+  document.getElementById("customer-panel"),
+  { moduleInputs: { customerId: "customer-42" } }
+);
+
+

+ bootstrapAppAt(appId: string, node: Element, options = {}): + Promise<AppViewInstance> +

+

+ The low-level imperative factory exported by the npm package. Unlike + browser mount, it requires an element, not a selector. + Supply the same initialization options, with appId as + the first argument. The browser bundle also exposes the broader + exports through window.$lowcoder_sdk; prefer + LowcoderSDK.mount for browser integrations. +

+

+ In current source, the factory checks for a node, waits for + instance.ready, and cleans up on initialization + failure. It does not track or replace an existing root on that + element. The published npm 3.0.0 factory returns before definition + loading finishes, and its instance has neither + ready nor destroy. Its declaration + requires the options argument, so pass {} when using + defaults. Use the hosted facade for screens requiring the documented + cleanup lifecycle until an npm release includes those fixes. +

+
+
+

LowcoderAppView

+
+
React / JSX
+
import { useMemo } from "react";
+import { LowcoderAppView } from "lowcoder-sdk";
+import "lowcoder-sdk/dist/style.css";
+
+export function CustomerPanel({ customerId }) {
+  const inputs = useMemo(() => ({ customerId }), [customerId]);
+  return (
+    <div style={{ minHeight: 360 }}>
+      <LowcoderAppView
+        appId="YOUR_PUBLISHED_MODULE_ID"
+        className="customer-module"
+        moduleInputs={inputs}
+        onModuleOutputChange={(output) => console.log(output)}
+        onModuleEventTriggered={(name) => console.log(name)}
+      />
+    </div>
+  );
+}
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropMeaning
appId + Required string: the published application or module ID. +
All six initialization options + baseUrl, webUrl, + orgId, moduleInputs, + appDsl, moduleDslMap. +
className + Optional string on the wrapper element. Set size through a + host wrapper or CSS. +
+ onModuleOutputChange + + Optional (output) => void; same payload as + the instance output event. +
+ onModuleEventTriggered + Optional (eventName: string) => void.
ref + Forwarded instance reference; initially undefined. Not a + guarantee that all components or queries have finished. +
+
+

Lifecycle in current source

+

+ Changes to appId, baseUrl, + webUrl or orgId recreate the instance. A + new moduleInputs object updates inputs; callback prop + changes replace subscriptions. Changes to appDsl or + moduleDslMap alone do not reload the app: change the + React key when replacing a supplied definition. Cleanup + disposes the owned instance. +

+

+ These lifecycle fixes are not in npm 3.0.0. Do not + rely on cleanup or backend switching described above in that + published version. The npm React wrapper also does not provide the + new instance readiness/disposal API. +

+

+ Arbitrary HTML props such as style and + id are not forwarded; put them on your host wrapper. + There is no public onError prop. The current wrapper + logs initial mount failures to the console. Use the hosted + imperative API if the host needs to own loading, failure and retry + UI. +

+

+ The wrapper uses the same single event-handler slots as + instance.on. Registering a second handler through the + ref can replace the corresponding callback prop subscription. +

+
+
+

LowcoderSDK.scan()

+
+
JavaScript
+
await LowcoderSDK.ready;
+await LowcoderSDK.scan(document.getElementById("new-widgets"));
+
+

+ scan(root: ParentNode = document): Promise<void[]> +

+

+ Finds descendant .lowcoder-module-container elements + and mounts their display elements. The root itself is not included. + The browser entry scans the document automatically at DOM readiness. + Call this again for markup inserted later. +

+

+ Skips containers without a display element and display elements + already tracked by the facade. It returns an array of undefined + results, not app instances. Individual JSON or mount errors are + caught and emitted on the affected container as + lowcoder:error; the scan can resolve even if an embed + failed. An invalid root can reject. +

+

+ Listen directly to each container for its events. Use explicit mount + when you need an instance, individual failure handling or lifecycle + control. +

+
+
+

Declarative HTML contract

+
+
HTML
+
<div class="lowcoder-module-container" id="customer-widget">
+  <input type="hidden" class="module-id" value="YOUR_PUBLISHED_MODULE_ID">
+  <input type="hidden" class="module-input" value='{"customerId":"customer-42"}'>
+  <input type="hidden" class="locoder-backend-url" value="https://api-service.lowcoder.cloud">
+  <input type="hidden" class="locoder-frontend-url" value="https://app.lowcoder.cloud">
+  <input type="hidden" class="locoder-org-id" value="YOUR_WORKSPACE_ID">
+  <div class="lowcoder-module-display"></div>
+</div>
+<script src="https://sdk.lowcoder.cloud/bundle.js"></script>
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassMeaning
lowcoder-module-containerParent container found by the scanner.
lowcoder-module-displayDescendant DOM element where the app is mounted.
module-idInput whose value is the required app ID.
module-input + Input containing a JSON object of module inputs; defaults to + {}. +
locoder-backend-url + Optional backend URL; same default as baseUrl. +
locoder-frontend-url + Optional frontend URL; same default as webUrl. +
locoder-org-idOptional workspace login ID.
+
+

+ The historical spelling locoder-… is required. Changing + a hidden field after mounting does not update that instance; the + scanner skips existing mounts. Unmount its display element before + scanning again, or use explicit mount and + setModuleInputs. +

+

+ The scanner also calls optional globals + window.opChange(output) and + window.onModuleEventTriggered(eventName). They are + shared across all scanned embeds and carry no instance identity. + Prefer listeners on the individual container. A throwing global + callback can interrupt subsequent container event delivery. +

+
+
+

Browser DOM events

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Event targetNameevent.detail
windowlowcoder:readyNo payload. Initial browser runtime loaded.
windowlowcoder:error + Error from initial loader failure; not a catch-all runtime + error event. +
Scanned containerlowcoder:outputWhole module output object.
Scanned containerlowcoder:eventModule event name string.
Scanned containerlowcoder:errorError parsing its JSON or mounting its app.
+
+
+
JavaScript
+
const container = document.getElementById("customer-widget");
+container.addEventListener("lowcoder:output", (event) => {
+  console.log(event.detail);
+});
+container.addEventListener("lowcoder:error", (event) => {
+  console.error("This embed failed", event.detail);
+});
+
+

+ These CustomEvents do not bubble. Register on the + stated target before the event can fire. Explicit + mount() does not emit the scanner’s container events; + subscribe on its returned instance instead. Prefer the ready promise + to a window readiness listener that could be attached too late. +

+
+
+

Authentication & deployment boundaries

+

+ The initial app request uses credentials/cookies. An initial + 401 displays a built-in login prompt. Login opens + {webUrl}/user/auth/login, or + {webUrl}/org/{orgId}/auth/login when a workspace is + supplied. The prompt’s Refresh action reloads the + entire host page. Other initial failures, including + 403, reject hosted mounting. +

+

+ Configure backend CORS and cookie settings for the actual host + origin, and test the browser’s cross-site cookie behavior. CORS on + the SDK’s static files does not grant access to the Lowcoder API. + There is no public bearer-token option, request-header hook, logout + method or automatic transfer of your host’s SSO session. +

+

+ Multiple embeds can coexist, but the runtime shares configuration, + Redux state, history and CSS. Use one Lowcoder deployment per page. + Passing a different backend to each instance does not provide + independent isolation. Native DOM integration is not a Shadow DOM or + CSS sandbox; test host styles, overlays and routing. +

+

+ To self-host, serve bundle.js with its complete emitted + asset directory. The loader resolves chunks relative to its own URL. + Retain old hashed assets during upgrades so already open pages can + still load their lazy chunks. The loader propagates its script nonce + to initial scripts; this is not a guarantee that the full runtime + supports your strict CSP. +

+
+
+

Troubleshooting by symptom

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymptomWhat to check
The page stays blank + Catch LowcoderSDK.ready and + mount() errors. Check chunk requests, backend + access, container size and runtime console errors. +
Inputs appear unchanged + Embed a module; declare the exact input names and types. + Send a fresh map. Serialize Data/Array values. Omitted keys + do not reset values. +
A method does nothing + Use a declared module method, wait for the rendered module + to be usable, and match parameter order. Mount readiness + alone is insufficient. +
An awaited method finishes instantly + invokeMethod returns void. Expose completion + through your module contract. +
A listener stops firing + There is one handler per event. Another registration or a + React callback prop may have replaced it. +
An initial output was missed + Subscriptions have no replay. Avoid depending on a single + startup emission for a handshake. +
A private embed shows Login + Mount can resolve to the auth prompt. Verify cookies, + allowed origins, publication/access settings and frontend + login URL. +
destroy / ready / appView import is missing + Check the distribution table; npm + 3.0.0 is not equivalent to the hosted bundle. +
Host styles or another embed change + Configuration, routing and CSS are shared. Keep the same + deployment across embeds and check host integration + boundaries. +
+
+

+ This reference is derived from the browser facade, loader, + AppViewInstance, bootstrap factory, React wrapper and + module input/method implementations in + the Lowcoder client source. The distribution notes record the separately inspected npm + artifact. Repository changes and published artifacts can differ; the + version table makes that distinction explicit. +

+
+
+
+ + + diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/reference.js b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.js new file mode 100644 index 0000000000..c817debac2 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/reference.js @@ -0,0 +1,73 @@ +(() => { + const search = document.getElementById("reference-search"); + const clear = document.getElementById("clear-search"); + const status = document.getElementById("search-status"); + const empty = document.getElementById("search-empty"); + const sections = Array.from(document.querySelectorAll("article > section")); + const links = Array.from(document.querySelectorAll("#reference-nav a")); + const searchable = sections.map((section) => + section.textContent.toLowerCase(), + ); + + function filter() { + const terms = search.value + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + let count = 0; + sections.forEach((section, index) => { + const matches = terms.every((term) => searchable[index].includes(term)); + section.hidden = !matches; + links[index].hidden = !matches; + if (matches) count++; + }); + clear.hidden = !search.value; + empty.hidden = count !== 0; + status.textContent = terms.length + ? `${count} matching ${count === 1 ? "section" : "sections"}` + : ""; + } + search.addEventListener("input", filter); + clear.addEventListener("click", () => { + search.value = ""; + filter(); + search.focus(); + }); + // A cross-reference must still reveal its target while a filter is active. + function revealHash() { + let id; + try { + id = decodeURIComponent(location.hash.slice(1)); + } catch { + return; + } + const target = document.getElementById(id); + if (target?.hidden && sections.includes(target)) { + search.value = ""; + filter(); + target.scrollIntoView(); + } + } + window.addEventListener("hashchange", revealHash); + revealHash(); + + document.querySelectorAll(".code-card").forEach((card, index) => { + const pre = card.querySelector("pre"); + pre.id = `example-${index + 1}`; + const button = document.createElement("button"); + button.className = "copy-button"; + button.type = "button"; + button.textContent = "Copy"; + button.setAttribute("aria-label", `Copy example ${index + 1}`); + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(pre.textContent); + button.textContent = "Copied"; + } catch { + button.textContent = "Select code to copy"; + } + }); + card.querySelector(".code-toolbar").appendChild(button); + }); +})(); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/site/site.css b/client/packages/lowcoder-sdk-webpack-bundle/site/site.css new file mode 100644 index 0000000000..923f5f2be2 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/site/site.css @@ -0,0 +1,1286 @@ +/* Brand tokens and original assets shared with the Lowcoder website. */ +@font-face { + font-family: Ubuntu; + src: url("/brand/ubuntu-300.woff2") format("woff2"); + font-weight: 300; + font-display: swap; +} +@font-face { + font-family: Ubuntu; + src: url("/brand/ubuntu-400.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: Ubuntu; + src: url("/brand/ubuntu-500.woff2") format("woff2"); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: Ubuntu; + src: url("/brand/ubuntu-700.woff2") format("woff2"); + font-weight: 700; + font-display: swap; +} +html { + scroll-behavior: smooth; +} +.sdk-site { + --ink: #18101e; + --ink-deep: #080808; + --ink-soft: #231629; + --purple: #b480de; + --purple-bright: #b55ff2; + --purple-deep: #683e86; + --lavender: #e3c2f2; + --lavender-soft: #f8efff; + --paper: #fcf8ff; + --white: #fff; + --muted: #726777; + --accent: var(--purple-deep); + --line: rgba(104, 62, 134, 0.18); + --gradient: linear-gradient(90deg, var(--purple-deep), var(--purple-bright)); + --shadow: 0 26px 90px rgba(57, 21, 78, 0.12); + margin: 0; + min-width: 320px; + background: var(--white); + color: var(--ink); + font: + 400 16px/1.6 Ubuntu, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + -webkit-font-smoothing: antialiased; +} +.sdk-site * { + box-sizing: border-box; +} +.sdk-site a { + color: inherit; + text-decoration: none; +} +.sdk-site button, +.sdk-site select, +.sdk-site input { + font: inherit; +} +.sdk-site button, +.sdk-site a, +.sdk-site select { + -webkit-tap-highlight-color: transparent; +} +.sdk-site :focus-visible { + outline: 3px solid var(--purple); + outline-offset: 4px; +} +.sdk-site h1, +.sdk-site h2, +.sdk-site h3, +.sdk-site p { + margin: 0; +} +.sdk-site h1, +.sdk-site h2, +.sdk-site h3 { + font-family: Ubuntu, sans-serif; + color: var(--ink); +} +.sdk-site h1 { + font-size: clamp(54px, 5.6vw, 76px); + font-weight: 400; + line-height: 1.12; + letter-spacing: -0.045em; +} +.sdk-site h2 { + font-size: clamp(36px, 4vw, 54px); + font-weight: 400; + line-height: 1.12; + letter-spacing: -0.045em; +} +.sdk-site h3 { + font-size: 24px; + font-weight: 500; + line-height: 1.35; + letter-spacing: -0.025em; +} +.sdk-site .wrap { + width: min(1200px, calc(100% - 80px)); + margin-inline: auto; +} +.sdk-site .site-header { + position: absolute; + z-index: 20; + top: 0; + left: 50%; + transform: translateX(-50%); + height: 88px; + padding-top: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; +} +.sdk-site .brand { + display: inline-flex; + align-items: center; + gap: 10px; + height: 56px; +} +.sdk-site .brand img { + width: 178px; + height: 56px; +} +.sdk-site .brand > span { + color: var(--lavender); + border-left: 1px solid rgba(180, 128, 222, 0.4); + padding-left: 14px; + font-size: 12px; + letter-spacing: 0.1em; +} +.sdk-site .site-nav { + position: fixed; + z-index: 30; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + display: flex; + justify-content: center; + gap: 4px; + padding: 5px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + background: rgba(24, 16, 30, 0.78); + box-shadow: 0 8px 36px rgba(24, 16, 30, 0.22); + backdrop-filter: blur(20px); +} +.sdk-site .site-nav a { + border-radius: 999px; + padding: 12px 24px; + font-size: 14px; + line-height: 1.5; + white-space: nowrap; + color: rgba(255, 255, 255, 0.76); + transition: + background 0.2s, + color 0.2s; +} +.sdk-site .site-nav a:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} +.sdk-site .site-nav a[aria-current] { + color: white; + background: var(--purple-deep); +} +.sdk-site .button { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 18px; + min-height: 50px; + padding: 12px 28px; + border: 0; + border-radius: 999px; + cursor: pointer; + font-size: 16px; + font-weight: 500; + line-height: 1.4; + text-align: center; + transition: + transform 0.2s, + box-shadow 0.2s, + filter 0.2s; +} +.sdk-site .primary { + background: var(--gradient); + color: white; + box-shadow: 0 10px 34px rgba(181, 95, 242, 0.25); +} +.sdk-site .secondary { + background: rgba(180, 128, 222, 0.1); + color: white; + border: 1px solid rgba(180, 128, 222, 0.5); +} +.sdk-site .button:hover { + transform: translateY(-2px); + filter: saturate(1.15); + box-shadow: 0 12px 40px rgba(181, 95, 242, 0.3); +} +.sdk-site .login-button { + margin-top: 3px; +} +.sdk-site button:disabled { + opacity: 0.6; + cursor: wait; +} +.sdk-site .eyebrow { + color: var(--purple-deep); + font-size: 12px; + font-weight: 700; + line-height: 1.5; + letter-spacing: 0.14em; +} +.sdk-site .text-link { + font-size: 15px; + font-weight: 500; + color: var(--purple-deep); +} +.sdk-site .text-link:hover { + color: var(--purple-bright); +} +.sdk-site .hero, +.sdk-site .docs-hero { + position: relative; + background: var(--ink) url("/brand/hero-background.jpeg") center top / cover + no-repeat; + color: white; +} +.sdk-site .hero { + padding: 150px 0 90px; +} +.sdk-site .hero::after { + position: absolute; + content: ""; + bottom: -1px; + left: 0; + right: 0; + height: 75px; + background: linear-gradient( + transparent, + rgba(255, 255, 255, 0.45) 55%, + white + ); + pointer-events: none; +} +.sdk-site .hero-layout { + display: grid; + grid-template-columns: 1.2fr 1fr; + align-items: center; + gap: 55px; + min-height: 365px; +} +.sdk-site .hero h1 { + color: white; + margin: 22px 0 28px; +} +.sdk-site .hero h1 span { + color: var(--lavender); +} +.sdk-site .hero .eyebrow, +.sdk-site .docs-hero .eyebrow { + color: var(--purple); +} +.sdk-site .hero-copy > p { + max-width: 620px; + font-size: 18px; + line-height: 1.65; + color: rgba(255, 255, 255, 0.7); +} +.sdk-site .hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 22px; + margin-top: 38px; +} +.sdk-site .hero-visual { + overflow: hidden; + border-radius: 22px; + border: 1px solid rgba(180, 128, 222, 0.3); + background: rgba(24, 16, 30, 0.76); + box-shadow: + 0 28px 100px rgba(0, 0, 0, 0.3), + 0 0 80px rgba(181, 95, 242, 0.12); + transform: rotate(2deg); +} +.sdk-site .visual-bar { + display: flex; + align-items: center; + gap: 24px; + padding: 14px 18px; + border-bottom: 1px solid rgba(180, 128, 222, 0.2); + color: rgba(255, 255, 255, 0.7); + font-size: 12px; +} +.sdk-site .window-dots { + letter-spacing: 3px; + color: var(--purple); +} +.sdk-site .visual-shell { + display: grid; + grid-template-columns: 52px 1fr; +} +.sdk-site .visual-sidebar { + border-right: 1px solid rgba(180, 128, 222, 0.16); + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 20px; +} +.sdk-site .visual-sidebar span { + width: 18px; + height: 18px; + border: 1px solid rgba(180, 128, 222, 0.4); + border-radius: 5px; +} +.sdk-site .visual-sidebar span:nth-child(2) { + background: var(--purple-deep); +} +.sdk-site .visual-content { + padding: 24px; +} +.sdk-site .visual-heading { + color: rgba(255, 255, 255, 0.72); + font-size: 14px; + margin-bottom: 18px; +} +.sdk-site .visual-module { + padding: 20px; + border: 1px solid rgba(180, 128, 222, 0.5); + border-radius: 16px; + background: linear-gradient( + 135deg, + rgba(104, 62, 134, 0.35), + rgba(181, 95, 242, 0.08) + ); +} +.sdk-site .visual-module img { + width: 134px; + height: auto; + display: block; + margin-bottom: 16px; +} +.sdk-site .visual-module strong { + display: block; + font-size: 24px; + line-height: 1.3; + font-weight: 400; + color: white; +} +.sdk-site .visual-fields { + display: flex; + gap: 10px; + margin: 22px 0; +} +.sdk-site .visual-fields span { + height: 8px; + width: 65%; + background: rgba(180, 128, 222, 0.2); + border-radius: 8px; +} +.sdk-site .visual-fields span + span { + width: 35%; +} +.sdk-site .visual-data { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.sdk-site .visual-data span { + padding: 4px 10px; + border: 1px solid rgba(180, 128, 222, 0.3); + border-radius: 999px; + color: var(--lavender); + font-size: 10px; +} +.sdk-site .visual-content > p { + margin-top: 18px; + font-size: 11px; + color: rgba(255, 255, 255, 0.55); +} +.sdk-site .proof-strip { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 16px; + margin-top: 64px; + padding-top: 22px; + border-top: 1px solid rgba(180, 128, 222, 0.25); + font-size: 12px; + color: rgba(255, 255, 255, 0.68); +} +.sdk-site .proof-strip span::before { + content: "↳"; + margin-right: 9px; + color: var(--purple); +} +.sdk-site .playground-section { + padding: 75px 0 90px; + scroll-margin-top: 30px; +} +.sdk-site .section-intro { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 50px; + margin-bottom: 36px; +} +.sdk-site .section-intro .eyebrow { + margin-bottom: 18px; +} +.sdk-site .section-intro h2 { + max-width: 710px; +} +.sdk-site .section-intro > p { + max-width: 345px; + flex-shrink: 0; + color: var(--muted); + font-size: 16px; + line-height: 1.7; +} +.sdk-site .workbench { + border: 1px solid var(--line); + border-radius: 24px; + background: white; + overflow: hidden; + box-shadow: var(--shadow); +} +.sdk-site .browser-bar { + min-height: 46px; + background: var(--paper); + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + gap: 25px; + padding: 0 20px; + font: 11px/1.4 monospace; + color: var(--muted); +} +.sdk-site .native-badge { + margin-left: auto; + color: var(--purple-deep); + font: + 500 10px Ubuntu, + sans-serif; + background: var(--lavender-soft); + border-radius: 20px; + padding: 5px 10px; + white-space: nowrap; +} +.sdk-site .demo-shell { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + min-height: 385px; +} +.sdk-site .demo-sidebar { + padding: 28px 20px; + background: var(--paper); + display: flex; + flex-direction: column; + gap: 12px; + font-size: 12px; + border-right: 1px solid var(--line); +} +.sdk-site .demo-sidebar strong { + font-size: 20px; + margin-bottom: 26px; + letter-spacing: -0.6px; +} +.sdk-site .demo-sidebar strong span { + font-size: 10px; + font-weight: 400; + letter-spacing: 0; +} +.sdk-site .sidebar-label { + font-size: 9px; + color: var(--muted); + letter-spacing: 1px; +} +.sdk-site .demo-sidebar > .selected { + background: var(--lavender-soft); + padding: 9px; + border-radius: 7px; + margin: 0 -9px; + color: var(--purple-deep); + font-weight: 500; + display: flex; + justify-content: space-between; +} +.sdk-site .sidebar-footer { + margin-top: auto; + padding-top: 35px; +} +.sdk-site .sidebar-footer small { + font-size: 10px; + color: var(--muted); +} +.sdk-site .demo-main { + padding: 28px; + min-width: 0; +} +.sdk-site .host-controls { + display: flex; + justify-content: space-between; + gap: 20px; + margin-bottom: 27px; +} +.sdk-site .host-controls h3 { + font-size: 18px; + margin-top: 9px; +} +.sdk-site .host-controls .eyebrow { + font-size: 9px; +} +.sdk-site .host-controls label { + font-size: 11px; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 190px; + color: var(--muted); +} +.sdk-site select, +.sdk-site input { + border: 1px solid var(--line); + background: white; + color: var(--ink); + padding: 10px 12px; + border-radius: 8px; + min-width: 0; + min-height: 42px; + font-size: 13px; + max-width: 100%; +} +.sdk-site .dot { + display: inline-block; + vertical-align: middle; + width: 6px; + height: 6px; + background: var(--purple-bright); + border-radius: 50%; +} +.sdk-site .embed-label { + display: flex; + justify-content: space-between; + border: 1px solid rgba(104, 62, 134, 0.3); + border-bottom: 0; + border-radius: 10px 10px 0 0; + padding: 8px 12px; + background: var(--lavender-soft); + font-size: 9px; + letter-spacing: 0.08em; + color: var(--purple-deep); +} +.sdk-site .embed-label .dot { + margin-right: 5px; +} +.sdk-site .embed-stage { + height: 300px; + min-height: 260px; + border: 1px solid rgba(104, 62, 134, 0.3); + border-radius: 0 0 10px 10px; + overflow: hidden; + padding: 12px; + background: white; +} +.sdk-site .demo-placeholder { + text-align: center; + padding: 16px 0 10px; +} +.sdk-site .code-glyph { + font: 22px monospace; + color: var(--purple-deep); +} +.sdk-site .demo-placeholder h3 { + font-size: 18px; + margin: 10px 0; +} +.sdk-site .demo-placeholder p { + font-size: 12px; + color: var(--muted); + margin-bottom: 17px; +} +.sdk-site .demo-placeholder button { + font-size: 13px; + min-height: 42px; + padding: 9px 20px; +} +.sdk-site .demo-status { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 11px; + color: var(--muted); + margin-top: 12px; +} +.sdk-site .quiet-button { + border: 0; + background: none; + text-decoration: underline; + color: var(--purple-deep); + padding: 0; + cursor: pointer; +} +.sdk-site .event-panel { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + border-top: 1px solid var(--line); + background: var(--paper); + padding: 18px 20px; + gap: 28px; + align-items: center; +} +.sdk-site .event-panel strong { + font-size: 13px; + font-weight: 500; +} +.sdk-site .event-panel .eyebrow { + display: block; + font-size: 9px; + letter-spacing: 0.1em; +} +.sdk-site .event-panel pre { + margin: 0; + font-size: 11px; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--purple-deep); + max-height: 140px; + overflow: auto; +} +.sdk-site .under-demo { + display: flex; + justify-content: space-between; + gap: 20px; + margin-top: 22px; + font-size: 12px; + color: var(--muted); +} +.sdk-site .under-demo a { + text-decoration: underline; + text-underline-offset: 3px; + color: var(--purple-deep); +} +.sdk-site .use-cases { + padding-top: 30px; + padding-bottom: 100px; +} +.sdk-site .use-case-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + margin-top: 36px; +} +.sdk-site .use-case-grid article { + padding: 30px; + border: 1px solid var(--line); + border-radius: 26px; + background: var(--paper); +} +.sdk-site .case-number { + font-size: 13px; + color: var(--purple-deep); +} +.sdk-site .use-case-grid h3 { + font-size: 25px; + margin: 22px 0 16px; +} +.sdk-site .use-case-grid p { + font-size: 15px; + color: var(--muted); +} +.sdk-site .case-foot { + font-size: 12px; + color: var(--purple-deep); + display: block; + margin-top: 28px; +} +.sdk-site .quickstart { + border-top: 1px solid var(--line); + padding-top: 80px; + padding-bottom: 90px; + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: 75px; +} +.sdk-site .quickstart h2 { + margin: 18px 0 24px; +} +.sdk-site .quickstart p { + font-size: 16px; + color: var(--muted); + margin-bottom: 25px; + max-width: 350px; +} +.sdk-site .install-line { + margin-top: 35px; + display: flex; + flex-direction: column; + gap: 7px; + font-size: 12px; + color: var(--muted); +} +.sdk-site .install-line code { + font-size: 14px; + color: var(--ink); +} +.sdk-site .code-card { + background: var(--ink); + color: #eadcf3; + border: 1px solid rgba(180, 128, 222, 0.2); + border-radius: 18px; + min-width: 0; + align-self: start; + overflow: hidden; +} +.sdk-site .code-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid rgba(180, 128, 222, 0.2); + color: var(--lavender); + font-size: 12px; +} +.sdk-site .copy-button { + background: rgba(180, 128, 222, 0.1); + color: inherit; + border: 1px solid rgba(180, 128, 222, 0.35); + border-radius: 999px; + min-height: 32px; + padding: 5px 14px; + cursor: pointer; + font-size: 12px; +} +.sdk-site .copy-button:hover { + background: var(--purple-deep); +} +.sdk-site .code-card pre { + font: + 12px/1.9 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; + padding: 24px; +} +.sdk-site .final-note { + text-align: center; + border-top: 1px solid var(--line); + padding: 75px 0 100px; +} +.sdk-site .final-note p { + color: var(--muted); + margin: 20px 0 30px; +} +.sdk-site .site-footer { + padding: 85px 0 100px; + background: var(--ink-deep); + color: white; +} +.sdk-site .footer-inner { + display: grid; + grid-template-columns: minmax(260px, 1fr) 1.3fr; + gap: 90px; +} +.sdk-site .footer-intro img { + width: 178px; + height: 56px; +} +.sdk-site .footer-intro p { + max-width: 450px; + margin: 30px 0 28px; + color: rgba(255, 255, 255, 0.62); + font-size: 22px; + font-weight: 400; + line-height: 1.55; +} +.sdk-site .footer-intro .text-link { + color: var(--purple); +} +.sdk-site .footer-links { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 35px; +} +.sdk-site .footer-links > div { + display: grid; + align-content: start; + gap: 26px; +} +.sdk-site .footer-links > div + div { + padding-left: 30px; + border-left: 1px solid #222; +} +.sdk-site .footer-links a { + display: grid; + gap: 7px; +} +.sdk-site .footer-links strong { + color: white; + font-size: 16px; + font-weight: 500; +} +.sdk-site .footer-links span { + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + line-height: 1.45; +} +.sdk-site .footer-links a:hover strong { + color: var(--purple); +} +.sdk-site .footer-bottom { + margin-top: 65px; + border-top: 1px solid #222; + padding-top: 22px; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; +} +.sdk-site .skip { + position: absolute; + top: -60px; + left: 20px; + background: white; + padding: 10px; + z-index: 100; +} +.sdk-site .skip:focus { + top: 10px; +} +.sdk-site [hidden] { + display: none !important; +} +.sdk-site .docs-hero { + padding: 132px 0 66px; + text-align: center; +} +.sdk-site .docs-hero h1 { + color: white; + font-size: clamp(46px, 5vw, 68px); + margin: 20px 0 24px; +} +.sdk-site .docs-hero p { + color: rgba(255, 255, 255, 0.7); + max-width: 720px; + margin-inline: auto; + font-size: 18px; + line-height: 1.65; +} +.sdk-site .docs-hero .button { + margin-top: 30px; +} +.sdk-site .error-page { + padding-bottom: 110px; +} +.sdk-site .guide { + padding: 64px 0 90px; + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 60px; +} +.sdk-site .guide aside { + position: sticky; + top: 30px; + align-self: start; + display: flex; + flex-direction: column; + gap: 14px; + font-size: 14px; +} +.sdk-site .guide aside > strong { + font-weight: 500; + color: var(--ink); +} +.sdk-site .guide article { + max-width: 850px; + min-width: 0; +} +.sdk-site .guide h2 { + font-size: 30px; + line-height: 1.25; + margin-bottom: 18px; + letter-spacing: -0.025em; +} +.sdk-site .guide section { + padding: 35px 0; + border-bottom: 1px solid var(--line); + scroll-margin-top: 30px; +} +.sdk-site .guide article > section:first-child { + padding-top: 0; +} +.sdk-site .guide p { + margin: 12px 0; + color: var(--muted); +} +.sdk-site .guide .code-card { + margin: 22px 0; +} +.sdk-site .guide table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.sdk-site .guide td, +.sdk-site .guide th { + padding: 14px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} +.sdk-site .guide th { + background: var(--lavender-soft); + font-weight: 500; +} +.sdk-site .guide a:not(.button) { + color: var(--purple-deep); + text-decoration: underline; + text-underline-offset: 4px; +} +.sdk-site .guide aside a { + text-decoration: none; +} +.sdk-site .guide aside a:hover { + color: var(--purple-bright); +} +.sdk-site .guide li { + margin: 8px 0; + color: var(--muted); +} +.sdk-site .guide code { + overflow-wrap: anywhere; +} +.sdk-site .notice { + padding: 20px 24px; + background: var(--lavender-soft); + border-left: 3px solid var(--purple); + border-radius: 0 12px 12px 0; + color: var(--purple-deep); + font-size: 14px; +} +.sdk-site .table-scroll { + overflow-x: auto; + margin: 22px 0; +} +.sdk-site .table-scroll table { + min-width: 530px; +} +@media (max-width: 1000px) { + .sdk-site .hero-layout { + gap: 30px; + grid-template-columns: 1.15fr 1fr; + } + .sdk-site .hero h1 { + font-size: 59px; + } + .sdk-site .hero-visual { + transform: none; + } + .sdk-site .visual-content { + padding: 18px; + } + .sdk-site .visual-shell { + grid-template-columns: 36px 1fr; + } + .sdk-site .visual-sidebar { + padding-inline: 8px; + } + .sdk-site .section-intro { + gap: 30px; + } + .sdk-site .section-intro > p { + max-width: 290px; + } + .sdk-site .demo-shell { + grid-template-columns: 145px minmax(0, 1fr); + } + .sdk-site .demo-sidebar { + padding: 22px 15px; + } + .sdk-site .demo-sidebar strong span { + display: none; + } + .sdk-site .host-controls { + flex-direction: column; + } + .sdk-site .host-controls label { + max-width: 250px; + } + .sdk-site .event-panel { + grid-template-columns: 140px minmax(0, 1fr); + gap: 10px; + } + .sdk-site .quickstart { + gap: 35px; + } + .sdk-site .under-demo { + flex-direction: column; + gap: 8px; + } + .sdk-site .guide { + grid-template-columns: 175px minmax(0, 1fr); + gap: 35px; + } + .sdk-site .footer-inner { + gap: 40px; + } + .sdk-site .use-case-grid { + gap: 16px; + } + .sdk-site .use-case-grid article { + padding: 24px; + } +} +@media (max-width: 760px) { + .sdk-site .wrap { + width: calc(100% - 40px); + } + .sdk-site .hero { + padding-top: 128px; + padding-bottom: 75px; + } + .sdk-site .hero-layout { + grid-template-columns: 1fr; + gap: 40px; + } + .sdk-site .hero h1 { + font-size: clamp(48px, 9vw, 68px); + } + .sdk-site .hero-visual { + max-width: 480px; + width: 100%; + margin: 0 auto; + } + .sdk-site .proof-strip { + display: grid; + grid-template-columns: 1fr 1fr; + font-size: 11px; + margin-top: 36px; + } + .sdk-site .site-nav { + width: min(510px, calc(100% - 24px)); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + } + .sdk-site .site-nav a { + text-align: center; + padding: 12px 5px; + font-size: 12px; + } + .sdk-site .section-intro { + flex-direction: column; + align-items: flex-start; + gap: 20px; + } + .sdk-site .section-intro > p { + max-width: none; + } + .sdk-site .playground-section { + padding: 50px 0 65px; + } + .sdk-site .use-cases { + padding-top: 15px; + padding-bottom: 65px; + } + .sdk-site .use-case-grid { + grid-template-columns: 1fr; + margin-top: 30px; + } + .sdk-site .use-case-grid article { + padding: 28px; + } + .sdk-site .use-case-grid h3 { + margin: 12px 0; + } + .sdk-site .case-foot { + margin-top: 20px; + } + .sdk-site .quickstart { + grid-template-columns: 1fr; + padding: 60px 0; + } + .sdk-site .quickstart p { + max-width: none; + } + .sdk-site .final-note { + padding: 55px 0 65px; + } + .sdk-site .footer-inner { + grid-template-columns: 1fr; + gap: 48px; + } + .sdk-site .site-footer { + padding-top: 60px; + } + .sdk-site .footer-intro p { + font-size: 20px; + } + .sdk-site .footer-links { + gap: 20px; + } + .sdk-site .footer-links > div + div { + padding-left: 20px; + } + .sdk-site .footer-bottom { + margin-top: 45px; + } + .sdk-site .docs-hero { + padding: 120px 0 48px; + } + .sdk-site .docs-hero h1 { + font-size: clamp(42px, 8vw, 58px); + } + .sdk-site .docs-hero p { + font-size: 17px; + } + .sdk-site .guide { + display: block; + padding-top: 35px; + } + .sdk-site .guide aside { + position: static; + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--line); + } + .sdk-site .guide h2 { + font-size: 27px; + } +} +@media (max-width: 540px) { + .sdk-site .brand img { + width: 152px; + height: auto; + } + .sdk-site .brand > span { + padding-left: 10px; + font-size: 10px; + } + .sdk-site .login-button { + min-height: 44px; + padding: 10px 22px; + font-size: 14px; + } + .sdk-site .hero-copy > p { + font-size: 17px; + } + .sdk-site .hero-actions { + gap: 12px; + margin-top: 28px; + } + .sdk-site .hero-actions .button { + font-size: 14px; + padding: 12px 20px; + gap: 10px; + } + .sdk-site .hero-visual { + border-radius: 18px; + } + .sdk-site .visual-module strong { + font-size: 23px; + } + .sdk-site .eyebrow { + font-size: 10px; + } + .sdk-site .browser-bar { + gap: 10px; + font-size: 8px; + padding: 0 12px; + } + .sdk-site .native-badge { + font-size: 8px; + padding: 4px 6px; + } + .sdk-site .demo-sidebar { + display: none; + } + .sdk-site .demo-shell { + display: block; + } + .sdk-site .demo-main { + padding: 16px; + } + .sdk-site .host-controls h3 { + font-size: 17px; + } + .sdk-site .embed-stage { + height: 360px; + } + .sdk-site .embed-label { + font-size: 7px; + padding: 8px; + } + .sdk-site .demo-placeholder h3 { + font-size: 17px; + } + .sdk-site .demo-placeholder p { + font-size: 12px; + } + .sdk-site .event-panel { + grid-template-columns: 1fr; + padding: 16px; + gap: 10px; + } + .sdk-site .event-panel > div { + display: flex; + justify-content: space-between; + } + .sdk-site .guide { + font-size: 15px; + } + .sdk-site .guide ul, + .sdk-site .guide ol { + padding-left: 22px; + } + .sdk-site .code-card pre { + padding: 18px; + font-size: 11px; + } +} +@media (max-width: 380px) { + .sdk-site .wrap { + width: calc(100% - 32px); + } + .sdk-site .brand img { + width: 133px; + } + .sdk-site .site-nav a { + font-size: 11px; + } + .sdk-site .hero h1 { + font-size: 43px; + } + .sdk-site .hero-actions .button { + font-size: 12px; + padding: 12px 16px; + } + .sdk-site .visual-content { + padding: 14px; + } + .sdk-site .visual-module { + padding: 16px; + } + .sdk-site .visual-content > p { + font-size: 10px; + } + .sdk-site .visual-module strong { + font-size: 21px; + } + .sdk-site .footer-links { + grid-template-columns: 1fr; + gap: 32px; + } + .sdk-site .footer-links > div + div { + padding: 28px 0 0; + border-left: 0; + border-top: 1px solid #222; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + .sdk-site .button, + .sdk-site .site-nav a { + transition: none; + } + .sdk-site .button:hover { + transform: none; + } +} diff --git a/client/packages/lowcoder-sdk-webpack-bundle/src/dev-utils/buildVars.cjs b/client/packages/lowcoder-sdk-webpack-bundle/src/dev-utils/buildVars.cjs index 1f960bb229..20aaef534a 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/src/dev-utils/buildVars.cjs +++ b/client/packages/lowcoder-sdk-webpack-bundle/src/dev-utils/buildVars.cjs @@ -23,6 +23,8 @@ const buildVars = [ name: "REACT_APP_NODE_SERVICE_URL", defaultValue: "", }, + { name: "REACT_APP_HOCUSPOCUS_URL", defaultValue: "" }, + { name: "REACT_APP_HOCUSPOCUS_SECRET", defaultValue: "" }, { name: "REACT_APP_ENV", defaultValue: "production", @@ -59,4 +61,4 @@ const buildVars = [ module.exports = { buildVars, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder-sdk-webpack-bundle/src/loader.js b/client/packages/lowcoder-sdk-webpack-bundle/src/loader.js new file mode 100644 index 0000000000..ea3850ec69 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/src/loader.js @@ -0,0 +1,28 @@ +(function () { + if (window.LowcoderSDK && window.LowcoderSDK.ready) return; + var source = document.currentScript; + var base = new URL(".", source.src); + var api = window.LowcoderSDK = {}; + var files = __SDK_FILES__; + api.ready = Promise.all(files.map(function (file) { + return new Promise(function (resolve, reject) { + var script = document.createElement("script"); + script.src = new URL(file, base).href; + // Fetch in parallel, evaluate in entrypoint order (one React/runtime instance). + script.async = false; + script.crossOrigin = "anonymous"; + if (source.nonce) script.nonce = source.nonce; + script.onload = resolve; + script.onerror = function () { reject(new Error("Lowcoder SDK could not load " + script.src)); }; + (document.head || document.documentElement).appendChild(script); + }); + })).then(function () { + if (!api.mount) throw new Error("Lowcoder SDK did not initialize."); + window.dispatchEvent(new CustomEvent("lowcoder:ready")); + return api; + }); + api.ready.catch(function (error) { + window.dispatchEvent(new CustomEvent("lowcoder:error", { detail: error })); + console.error(error); + }); +})(); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/test/documentation.test.cjs b/client/packages/lowcoder-sdk-webpack-bundle/test/documentation.test.cjs new file mode 100644 index 0000000000..e5cdf4c245 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/test/documentation.test.cjs @@ -0,0 +1,92 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const { JSDOM } = require('jsdom'); +const root = path.resolve(__dirname, '..'); +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); +const document = new JSDOM(read('site/reference.html')).window.document; +const source = file => ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const declaration = (tree, name) => tree.statements.find(node => node.name?.text === name); +const names = nodes => nodes.map(node => node.name?.text).filter(Boolean); +const documented = attribute => [...document.querySelectorAll(`[${attribute}]`)].map(node => node.getAttribute(attribute)); + +test('reference covers each public instance member, option, event and React prop in source', () => { + const instance = source('../lowcoder/src/appView/AppViewInstance.tsx'); + const members = declaration(instance, 'AppViewInstance').members.filter(node => + !ts.isConstructorDeclaration(node) && + !node.modifiers?.some(modifier => [ts.SyntaxKind.PrivateKeyword, ts.SyntaxKind.ProtectedKeyword].includes(modifier.kind)) + ); + for (const name of names(members)) assert.ok(documented('data-api').includes(`AppViewInstance.${name}`), name); + assert.deepEqual(documented('data-option').sort(), names(declaration(instance, 'AppViewInstanceOptions').members).sort()); + assert.deepEqual(documented('data-event').sort(), names(declaration(instance, 'EventHandlerMap').members).sort()); + const react = source('../lowcoder/src/appView/LowcoderAppView.tsx'); + assert.deepEqual(documented('data-prop').sort(), names(declaration(react, 'LowcoderAppViewProps').members).sort()); + for (const api of ['LowcoderAppView', 'bootstrapAppAt']) assert.ok(documented('data-api').includes(api), api); +}); + +test('reference covers the browser facade exported by the entry and loader', () => { + const expected = new Set(); + for (const file of ['index.jsx', 'src/loader.js']) { + function visit(node) { + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && node.left.expression.getText() === 'api') { + expected.add(`LowcoderSDK.${node.left.name.text}`); + } + ts.forEachChild(node, visit); + } + visit(source(file)); + } + assert.deepEqual(documented('data-api').filter(name => name.startsWith('LowcoderSDK.')).sort(), [...expected].sort()); +}); + +test('documentation has valid local anchors, unique IDs and parseable JS/JSX examples', () => { + const pages = new Map([ + ['/', new JSDOM(read('index.html')).window.document], + ...['guide', 'reference', 'recipes'].map(name => [`/${name}.html`, new JSDOM(read(`site/${name}.html`)).window.document]), + ]); + for (const [name, page] of pages) { + const ids = [...page.querySelectorAll('[id]')].map(node => node.id); + assert.equal(ids.length, new Set(ids).size, `${name}: duplicate IDs`); + for (const link of page.querySelectorAll('a[href]')) { + const href = link.getAttribute('href'); + if (!href.startsWith('/') && !href.startsWith('#')) continue; + const url = new URL(href, `https://sdk.lowcoder.cloud${name}`); + const target = pages.get(url.pathname); + if (target && url.hash) assert.ok(target.getElementById(url.hash.slice(1)), `${name}: broken ${href}`); + } + for (const card of page.querySelectorAll('.code-card')) { + const language = card.querySelector('.code-toolbar span')?.textContent; + if (!['JavaScript', 'React / JSX'].includes(language)) continue; + const example = ts.createSourceFile('example.tsx', card.querySelector('pre').textContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + assert.equal(example.parseDiagnostics.length, 0, `${name}: example syntax error`); + } + assert.equal(page.querySelectorAll('script[src*="bundle.js"]').length, 0, `${name}: docs must not download the runtime`); + } +}); + +test('search filters full section content, clears, and reveals a hidden cross-reference', () => { + const dom = new JSDOM(read('site/reference.html'), { url: 'https://sdk.lowcoder.cloud/reference.html', runScripts: 'outside-only' }); + const { window } = dom; + window.HTMLElement.prototype.scrollIntoView = function () {}; + window.eval(read('site/reference.js')); + const input = window.document.getElementById('reference-search'); + const search = value => { input.value = value; input.dispatchEvent(new window.Event('input')); }; + search('positional'); + assert.equal(window.document.getElementById('methods').hidden, false); + assert.equal(window.document.getElementById('sdk-ready').hidden, true); + assert.match(window.document.getElementById('search-status').textContent, /matching/); + search('not-a-real-api'); + assert.equal(window.document.getElementById('search-empty').hidden, false); + window.document.getElementById('clear-search').click(); + assert.equal(input.value, ''); + assert.equal(window.document.querySelectorAll('article > section[hidden]').length, 0); + search('positional'); + window.location.hash = '#sdk-ready'; + window.dispatchEvent(new window.HashChangeEvent('hashchange')); + assert.equal(window.document.getElementById('sdk-ready').hidden, false); + assert.equal(input.value, ''); + assert.equal(window.document.querySelectorAll('.copy-button').length, window.document.querySelectorAll('.code-card').length); + window.close(); +}); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/test/instance.test.cjs b/client/packages/lowcoder-sdk-webpack-bundle/test/instance.test.cjs new file mode 100644 index 0000000000..f8cfa23819 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/test/instance.test.cjs @@ -0,0 +1,53 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const lodash = require('lodash'); +const source = fs.readFileSync(path.join(__dirname, '../../lowcoder/src/appView/AppViewInstance.tsx'), 'utf8'); +const code = ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020,esModuleInterop:true}}).outputText; +function fixture(get = async () => { throw new Error('API unavailable'); }) { + const jsx = (type, props) => ({type,props}); + const dependencies = { + lodash, axios: { create: () => ({ get }) }, + 'react/jsx-runtime': {jsx, jsxs:jsx}, react: {lazy:() => 'AppView', Suspense:'Suspense'}, + 'styled-components': {StyleSheetManager:'Styles'}, 'antd/es/flex':'Flex', + 'components/button':{TacoButton:'Button'}, 'comps/utils/globalSettings':{setGlobalSettings(){}}, + 'constants/sdkConfig':{sdkConfig:{}}, 'constants/apiConstants':{API_STATUS_CODES:{REQUEST_NOT_AUTHORISED:401}}, + 'constants/routesURL':{AUTH_LOGIN_URL:'/auth/login'}, 'constants/authConstants':{AuthSearchParams:{}}, + 'pages/userAuth/authUtils':{saveAuthSearchParams(){}}, + '@lowcoder-ee/api/datasourceApi':{DatasourceApi:{fetchJsDatasourceByApp:async () => ({data:{data:[]}})}}, + '@lowcoder-ee/constants/queryConstants':{registryDataSourcePlugin(){}}, + }; + const context = vm.createContext({ exports:{}, require(name) { if (!(name in dependencies)) throw Error(name); return dependencies[name]; }, window:{location:{href:'https://host.example'}} }); + vm.runInContext(code, context); + const rendered=[]; let unmounts=0; + return {Instance:context.exports.AppViewInstance, root:{render(tree){rendered.push(tree)},unmount(){unmounts++}}, rendered, unmounts:()=>unmounts}; +} +const dsl = {ui:{compType:'module',comp:{io:{inputs:[{name:'value',defaultValue:{comp:'default'}}]}}}}; +test('preserves false, zero and empty inputs without mutating the supplied app definition', async () => { + for (const value of [0,false,'']) { + const f=fixture(); const app=new f.Instance('demo',{},f.root,{appDsl:dsl,moduleInputs:{value}}); + await app.ready; + const passed=f.rendered[0].props.children.props.children.props.dsl; + assert.equal(passed.ui.comp.io.inputs[0].defaultValue.comp,value); + assert.equal(dsl.ui.comp.io.inputs[0].defaultValue.comp,'default'); + } +}); +test('destroy is idempotent and prevents late rendering and callbacks', async () => { + const f=fixture(); const app=new f.Instance('demo',{},f.root,{appDsl:dsl}); let calls=0; + app.on('moduleEventTriggered',()=>calls++); app.destroy(); app.destroy(); await app.ready; + app.emit('moduleEventTriggered',['event']); app.setModuleInputs({value:1}); + assert.equal(calls,0); assert.equal(f.rendered.length,0); assert.equal(f.unmounts(),1); +}); +test('unsubscribing an old handler does not remove its replacement', async () => { + const f=fixture(); const app=new f.Instance('demo',{},f.root,{appDsl:dsl}); await app.ready; let calls=0; + const off=app.on('moduleEventTriggered',()=>{}); + app.on('moduleEventTriggered',()=>calls++); off(); app.emit('moduleEventTriggered',['event']); + assert.equal(calls,1); +}); +test('network errors reject readiness rather than render a blank app', async () => { + const f=fixture(); const app=new f.Instance('demo',{},f.root,{}); + await assert.rejects(app.ready,/API unavailable/); assert.equal(f.rendered.length,0); +}); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/test/loader.test.cjs b/client/packages/lowcoder-sdk-webpack-bundle/test/loader.test.cjs new file mode 100644 index 0000000000..fb2a151346 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/test/loader.test.cjs @@ -0,0 +1,50 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '../src/loader.js'), 'utf8').replace('__SDK_FILES__', JSON.stringify(['assets/runtime.abc.js', 'assets/vendor.def.js', 'assets/main.ghi.js'])); +function page() { + const scripts = [], events = []; + const window = { dispatchEvent(event) { events.push(event.type); } }; + const context = vm.createContext({ window, URL, Promise, CustomEvent: class { constructor(type) { this.type = type; } }, console: { error() {} }, document: { + currentScript: { src: 'https://preview.example/versions/3/bundle.js', nonce: 'nonce-value' }, + createElement: () => ({}), head: { appendChild(script) { scripts.push(script); } }, + }}); + vm.runInContext(source, context); + return { window, scripts, events, context }; +} +test('fetches every chunk immediately, preserves evaluation order, origin, path and CSP nonce', async () => { + const p = page(); + assert.equal(p.scripts.length, 3); + p.scripts.forEach(s => { assert.equal(s.async, false); assert.equal(s.nonce, 'nonce-value'); assert.equal(s.crossOrigin, 'anonymous'); }); + assert.equal(p.scripts[0].src, 'https://preview.example/versions/3/assets/runtime.abc.js'); + let ready = false; + p.window.LowcoderSDK.ready.then(() => { ready = true; }); + p.scripts[2].onload(); p.scripts[1].onload(); + await Promise.resolve(); assert.equal(ready, false); + p.window.LowcoderSDK.mount = () => {}; + p.scripts[0].onload(); + assert.equal(await p.window.LowcoderSDK.ready, p.window.LowcoderSDK); + assert.deepEqual(p.events, ['lowcoder:ready']); +}); +test('duplicate bundle tags share one runtime and one readiness promise', () => { + const p = page(), promise = p.window.LowcoderSDK.ready; + vm.runInContext(source, p.context); + assert.equal(p.scripts.length, 3); assert.equal(p.window.LowcoderSDK.ready, promise); +}); +test('a failed chunk rejects readiness and reports the failed URL', async () => { + const p = page(); p.scripts[1].onerror(); + await assert.rejects(p.window.LowcoderSDK.ready, /vendor.def.js/); + assert.deepEqual(p.events, ['lowcoder:error']); +}); +test('loaded scripts without an initialized entry do not report success', async () => { + const p = page(); p.scripts.forEach(s => s.onload()); + await assert.rejects(p.window.LowcoderSDK.ready, /did not initialize/); +}); +test('the browser build supplies every frontend build constant', () => { + const declarations = fs.readFileSync(path.join(__dirname, '../../lowcoder/src/app-env.d.ts'), 'utf8'); + const names = [...declarations.matchAll(/declare var (REACT_APP_\w+)/g)].map(m => m[1]); + const defined = new Set(require('../src/dev-utils/buildVars.cjs').buildVars.map(v => v.name)); + for (const name of names) assert.ok(defined.has(name), `Missing SDK build definition: ${name}`); +}); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/test/stable-release.test.cjs b/client/packages/lowcoder-sdk-webpack-bundle/test/stable-release.test.cjs new file mode 100644 index 0000000000..da169cde67 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/test/stable-release.test.cjs @@ -0,0 +1,13 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { validateRelease } = require('../scripts/check-stable-release.cjs'); +const stable = { tag_name: 'v3.0.1', draft: false, prerelease: false }; +test('only an exact stable main release passes publication checks', () => { + assert.doesNotThrow(() => validateRelease(stable, 'release', 'release', 'release')); + for (const invalid of [{ ...stable, draft: true }, { ...stable, prerelease: true }, { ...stable, tag_name: 'v3.0.1-rc1' }]) { + assert.throws(() => validateRelease(invalid, 'release', 'release', 'release')); + } + assert.throws(() => validateRelease(stable, 'feature', 'main', 'feature')); + assert.throws(() => validateRelease(stable, 'main', 'main', 'older-release')); + assert.throws(() => validateRelease(stable, 'old-main', 'new-main', 'old-main')); +}); diff --git a/client/packages/lowcoder-sdk-webpack-bundle/webpack.config.cjs b/client/packages/lowcoder-sdk-webpack-bundle/webpack.config.cjs index 02efbc1572..a25e548c01 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/webpack.config.cjs +++ b/client/packages/lowcoder-sdk-webpack-bundle/webpack.config.cjs @@ -95,8 +95,11 @@ module.exports = { }, output: { path: path.resolve(__dirname, 'dist'), - publicPath: "https://sdk.lowcoder.cloud/", - filename: '[name].bundle.js', + publicPath: "auto", + filename: 'assets/[name].[contenthash:12].js', + chunkFilename: 'assets/[name].[contenthash:12].js', + uniqueName: 'lowcoder-embed-sdk', + crossOriginLoading: 'anonymous', clean: true, }, plugins: [ @@ -128,22 +131,25 @@ module.exports = { from: 'src/custom_component/index_custom_component_files', // Path to your folder to: 'index_custom_component_files/', // Destination path in the dist folder }, - { - from: 'netlify.toml', // Path to your netlify.toml file - to: 'netlify.toml', // Destination filename in the dist folder - }, + { from: 'site', to: '.' }, ], }), isVisualizerEnabled && new BundleAnalyzerPlugin() - ], + ].filter(Boolean), optimization: { minimize: true, minimizer: [new TerserPlugin({ - // Your customization if any + parallel: 2, })], sideEffects: true, splitChunks: { chunks: 'all', + // Partition the module graph without creating independent runtimes. + maxSize: 1600000, + minSize: 60000, + maxInitialRequests: 40, + maxAsyncRequests: 40, + hidePathInfo: true, }, runtimeChunk: 'single', }, diff --git a/client/packages/lowcoder-sdk-webpack-bundle/wrangler.jsonc b/client/packages/lowcoder-sdk-webpack-bundle/wrangler.jsonc new file mode 100644 index 0000000000..409981f9c4 --- /dev/null +++ b/client/packages/lowcoder-sdk-webpack-bundle/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "lowcoder-sdk", + "account_id": "4727dece8192846e8e1425afe6715cb4", + "compatibility_date": "2026-10-11", + "workers_dev": true, + "routes": [ + { "pattern": "sdk.lowcoder.cloud/*", "zone_name": "lowcoder.cloud" } + ], + "assets": { + "directory": "./dist", + "not_found_handling": "404-page" + } +} diff --git a/client/packages/lowcoder-sdk/index.d.ts b/client/packages/lowcoder-sdk/index.d.ts index 03ca7ae38f..5afde470b8 100644 --- a/client/packages/lowcoder-sdk/index.d.ts +++ b/client/packages/lowcoder-sdk/index.d.ts @@ -16,6 +16,8 @@ export interface AppViewInstanceOptions { appDsl?: any; moduleDslMap?: any; baseUrl?: string; + webUrl?: string; + orgId?: string; } export interface LowcoderAppViewProps extends AppViewInstanceOptions { @@ -26,6 +28,8 @@ export interface LowcoderAppViewProps extends AppViewInstanceOptions { } export interface AppViewInstance { + readonly ready: Promise; + destroy(): void; on>(event: K, handler?: EventHandlerMap[K]): Off; setModuleInputs(inputs: I): void; invokeMethod(methodName: string, params?: any[]): void; diff --git a/client/packages/lowcoder-sdk/package.json b/client/packages/lowcoder-sdk/package.json index 6b60b3eb94..0846af07ff 100644 --- a/client/packages/lowcoder-sdk/package.json +++ b/client/packages/lowcoder-sdk/package.json @@ -18,12 +18,14 @@ "module": "./dist/lowcoder-sdk.js", "exports": { ".": { + "types": "./index.d.ts", "import": "./dist/lowcoder-sdk.js", "require": "./dist/lowcoder-sdk.js" }, "./appView": { - "import": "./dist/appView.js", - "require": "./dist/appView.js" + "types": "./index.d.ts", + "import": "./dist/lowcoder-sdk.js", + "require": "./dist/lowcoder-sdk.js" }, "./dataSource": { "types": "./dataSource.d.ts" @@ -70,5 +72,6 @@ "license": "MIT", "dependencies": { "prettier": "^3.1.1" - } + }, + "types": "./index.d.ts" } diff --git a/client/packages/lowcoder-sdk/vite.config.mts b/client/packages/lowcoder-sdk/vite.config.mts index 881e5fb730..697c6d0ce6 100644 --- a/client/packages/lowcoder-sdk/vite.config.mts +++ b/client/packages/lowcoder-sdk/vite.config.mts @@ -8,8 +8,8 @@ import { buildVars } from "./src/dev-utils/buildVars"; import { globalDepPlugin } from "./src/dev-utils/globalDepPlguin"; import dynamicImport from 'vite-plugin-dynamic-import'; import { visualizer } from "rollup-plugin-visualizer"; -import { terser } from 'rollup-plugin-terser'; import strip from "@rollup/plugin-strip"; +import { terser } from "rollup-plugin-terser"; const isVisualizerEnabled = !!process.env.ENABLE_VISUALIZER; @@ -36,7 +36,8 @@ export const viteConfig: UserConfig = { }, base: ensureLastSlash(process.env.PUBLIC_URL), build: { - minify: "terser", + // Vite 4 skips its Terser pass for ES libraries. Minify exactly once below. + minify: false, chunkSizeWarningLimit: 500, lib: { formats: ["es"], @@ -51,7 +52,7 @@ export const viteConfig: UserConfig = { tryCatchDeoptimization: false, unknownGlobalSideEffects: false, }, - external: ["react", "react-dom"], + external: [/^react(?:\/|$)/, /^react-dom(?:\/|$)/], output: { chunkFileNames: "chunks/[name]-[hash].js", entryFileNames: "lowcoder-sdk.js", @@ -68,9 +69,9 @@ export const viteConfig: UserConfig = { // TEXT EDITORS & PARSERS if (id.includes("codemirror")) return "codemirror"; + if (id.includes("react-quill")) return "react-quill"; if (id.includes("quill")) return "quill"; if (id.includes("react-json-view")) return "react-json-view"; - if (id.includes("react-quill")) return "react-quill"; if (id.includes("remark-gfm")) return "remark-gfm"; if (id.includes("rehype-raw")) return "rehype-raw"; if (id.includes("rehype-sanitize")) return "rehype-sanitize"; @@ -93,11 +94,11 @@ export const viteConfig: UserConfig = { // UTILITIES & HELPERS if (id.includes("clsx")) return "clsx"; if (id.includes("immer")) return "immer"; - if (id.includes("lodash")) return "lodash"; if (id.includes("lodash-es")) return "lodash-es"; + if (id.includes("lodash")) return "lodash"; if (id.includes("uuid")) return "uuid"; if (id.includes("ua-parser-js")) return "ua-parser-js"; - if (id.includes("html2canvas")) return "ua-parser-js"; + if (id.includes("html2canvas")) return "html2canvas"; if (id.includes("numbro")) return "numbro"; // FILE & DATA PROCESSING @@ -146,19 +147,11 @@ export const viteConfig: UserConfig = { return null; } }, - experimental: { - minChunkSize: 300000, // 📏 Force smaller chunks (~300KB) - }, plugins: [ terser({ - compress: { - drop_console: true, - drop_debugger: true, - pure_funcs: ["console.info", "console.debug", "console.log"], - }, - format: { - comments: /(@vite-ignore|webpackIgnore)/ - }, + numWorkers: 2, + compress: { drop_debugger: true, pure_funcs: ["console.info", "console.debug", "console.log"] }, + format: { comments: /(@vite-ignore|webpackIgnore)/ }, }) as PluginOption, strip({ functions: ["console.log", "debugger"], // ✅ Remove logs diff --git a/client/packages/lowcoder/src/appView/AppViewInstance.tsx b/client/packages/lowcoder/src/appView/AppViewInstance.tsx index 9c01c77724..89ae84f321 100644 --- a/client/packages/lowcoder/src/appView/AppViewInstance.tsx +++ b/client/packages/lowcoder/src/appView/AppViewInstance.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import type { RootComp } from "comps/comps/rootComp"; import { setGlobalSettings } from "comps/utils/globalSettings"; import { sdkConfig } from "constants/sdkConfig"; -import { get, set, isEqual } from "lodash"; +import { get, set, isEqual, cloneDeep } from "lodash"; import type { Root } from "react-dom/client"; import { StyleSheetManager } from "styled-components"; import type { ModuleDSL, ModuleDSLIoInput } from "types/dsl"; @@ -52,6 +52,8 @@ export class AppViewInstance { webUrl: "https://app.lowcoder.cloud", }; private authorizedUser: boolean = true; + private destroyed = false; + readonly ready: Promise; constructor(private appId: string, private node: Element, private root: Root, options: AppViewInstanceOptions = {}) { Object.assign(this.options, options); @@ -63,7 +65,7 @@ export class AppViewInstance { } this.dataPromise = this.loadData(); - this.render(); + this.ready = this.render(); } private isModuleDSL(dsl: any): dsl is ModuleDSL { @@ -73,7 +75,7 @@ export class AppViewInstance { private async loadData() { const { baseUrl, appDsl, moduleDslMap, webUrl } = this.options; - let finalAppDsl = appDsl; + let finalAppDsl = appDsl ? cloneDeep(appDsl) : appDsl; let finalModuleDslMap = moduleDslMap; setGlobalSettings({ @@ -102,9 +104,10 @@ export class AppViewInstance { } }; } + throw e; }); - await DatasourceApi.fetchJsDatasourceByApp(this.appId).then((res) => { + if (this.authorizedUser) await DatasourceApi.fetchJsDatasourceByApp(this.appId).then((res) => { res.data?.data?.forEach((i) => { registryDataSourcePlugin(i.type, i.id, i.pluginDefinition); }); @@ -122,7 +125,7 @@ export class AppViewInstance { const inputsPath = "ui.comp.io.inputs"; const nextInputs = get(finalAppDsl, inputsPath, []).map((i: ModuleDSLIoInput) => { const inputValue = this.options.moduleInputs[i.name]; - if (inputValue) { + if (inputValue !== undefined) { return { ...i, defaultValue: { @@ -143,6 +146,7 @@ export class AppViewInstance { } private handleCompChange(comp: RootComp | null) { + if (this.destroyed) return; this.comp = comp; if (!comp) { @@ -167,6 +171,7 @@ export class AppViewInstance { private async render() { const data = await this.dataPromise; + if (this.destroyed) return; const loginUrl = this.options.orgId ? `${this.options.webUrl}/org/${this.options.orgId}/auth/login` : `${this.options.webUrl}${AUTH_LOGIN_URL}`; @@ -226,10 +231,21 @@ export class AppViewInstance { return () => {}; } this.events.set(event, handler); - return () => this.events.delete(event); + return () => { + if (this.events.get(event) === handler) this.events.delete(event); + }; + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.events.clear(); + this.comp = null; + this.root.unmount(); } setModuleInputs(inputs: I) { + if (this.destroyed) return; this.options.moduleInputs = inputs; this.render(); } diff --git a/client/packages/lowcoder/src/appView/LowcoderAppView.tsx b/client/packages/lowcoder/src/appView/LowcoderAppView.tsx index 5800b7ea5b..61d40fdc26 100644 --- a/client/packages/lowcoder/src/appView/LowcoderAppView.tsx +++ b/client/packages/lowcoder/src/appView/LowcoderAppView.tsx @@ -30,16 +30,34 @@ function LowcoderAppViewBase( if (!node) { return; } - bootstrapAppAt(appId, node, options).then(setInstance); + // An effect owns its mount node, including during StrictMode's async cleanup. + const mountNode = document.createElement("div"); + node.appendChild(mountNode); + let cancelled = false; + let mounted: AppViewInstance | undefined; + setInstance(undefined); + bootstrapAppAt(appId, mountNode, options).then((next) => { + if (cancelled) next.destroy(); + else { mounted = next; setInstance(next); } + }).catch((error) => { + if (!cancelled) console.error("Lowcoder app could not mount", error); + }); + return () => { + cancelled = true; + mountNode.remove(); + // Avoid synchronously unmounting a nested root during React's commit. + if (mounted) queueMicrotask(() => mounted?.destroy()); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appId]); + }, [appId, options.baseUrl, options.webUrl, options.orgId]); useEffect(() => { if (!instance) { return; } - instance.on("moduleOutputChange", onModuleOutputChange); - instance.on("moduleEventTriggered", onModuleEventTriggered); + const offOutput = instance.on("moduleOutputChange", onModuleOutputChange); + const offEvent = instance.on("moduleEventTriggered", onModuleEventTriggered); + return () => { offOutput(); offEvent(); }; }, [instance, onModuleEventTriggered, onModuleOutputChange]); useEffect(() => { diff --git a/client/packages/lowcoder/src/appView/bootstrapAt.tsx b/client/packages/lowcoder/src/appView/bootstrapAt.tsx index ed31fd8c7e..f34aedbd79 100644 --- a/client/packages/lowcoder/src/appView/bootstrapAt.tsx +++ b/client/packages/lowcoder/src/appView/bootstrapAt.tsx @@ -8,12 +8,18 @@ export async function bootstrapAppAt( options: AppViewInstanceOptions = {} ) { if (!node) { - console.error("node must be not null."); - return; + throw new Error("bootstrapAppAt requires an existing DOM element."); } loadComps(); const { AppViewInstance } = await import("./AppViewInstance"); - return new AppViewInstance(appId, node, createRoot(node), options); + const instance = new AppViewInstance(appId, node, createRoot(node), options); + try { + await instance.ready; + return instance; + } catch (error) { + instance.destroy(); + throw error; + } } diff --git a/docs/build-applications/create-a-new-app/modules.md b/docs/build-applications/create-a-new-app/modules.md index 938797ca61..17520aa936 100644 --- a/docs/build-applications/create-a-new-app/modules.md +++ b/docs/build-applications/create-a-new-app/modules.md @@ -95,7 +95,7 @@ In the **Settings** tab, click **+** **Add** to create a new Module Method. Clic

From an App, one can call Methods of a embedded Module.

{% hint style="warning" %} -When creating a Method for a Module, you cannot create input parameters for the Method and can only reference parameters within the scope of the current Module. +Module methods can define named parameters and defaults. When calling a method from the native SDK, pass arguments in the configured order with `instance.invokeMethod(methodName, args)`. This call returns no result or completion promise; expose results through module outputs or events. See the [SDK method reference](https://sdk.lowcoder.cloud/reference.html#methods). {% endhint %} #### Call a Method