lua-cli 3.40.0 → 3.42.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-exports.d.ts +62 -18
- package/dist/api-exports.js +192 -21
- package/dist/api-exports.js.map +1 -1
- package/dist/index.js +2812 -602
- package/dist/index.js.map +1 -1
- package/dist/workflow-builder.d.ts +19 -3
- package/dist/workflow-builder.js +177 -8
- package/dist/workflow-builder.js.map +1 -1
- package/docs/workflows/reply-channels.md +2 -2
- package/docs/workflows/workspaces-and-long-steps.md +1 -1
- package/package.json +2 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/template-web-app/app.ts +29 -0
- package/template-web-app/web/index.html +12 -0
- package/template-web-app/web/package.json +27 -0
- package/template-web-app/web/src/App.tsx +28 -0
- package/template-web-app/web/src/index.css +3 -0
- package/template-web-app/web/src/lua/client.ts +254 -0
- package/template-web-app/web/src/main.tsx +16 -0
- package/template-web-app/web/tsconfig.json +14 -0
- package/template-web-app/web/vite.config.ts +9 -0
|
@@ -4,8 +4,8 @@ _Source of truth: workflows-spec (Cluster K B42). This page is the developer sum
|
|
|
4
4
|
|
|
5
5
|
A run started from a conversation carries `replyTo: { channel, threadId }`; at terminal state the platform sends the outcome back through a **`ChannelAdapter`** — a new channel is one adapter file + one registry entry.
|
|
6
6
|
|
|
7
|
-
- Registered today: **whatsapp**. `sms` / `email` / `webchat`
|
|
8
|
-
- Slack `threadId`
|
|
7
|
+
- Registered today: **whatsapp** and **slack**. `sms` / `email` / `webchat` are specified and land behind their E-10 pins; an unregistered channel fails loudly (`channel_unsupported`), never silently drops.
|
|
8
|
+
- Slack posts through `Channels.send` on the agent's own Slack connection, never another bot; the run's occurrence id is the idempotency key, so a redelivery never posts twice. `threadId` is any Slack `conversationId` (usually `<channelId>:<thread_ts>`; with several Slack connections use the room key `T…:A…:<channelId>:<thread_ts>`). No Slack connection, or the bot not in the channel, fails the reply permanently. Attachments go out as links.
|
|
9
9
|
- A step may answer the customer itself mid-run (`Channels.<channel>.send` inside `ctx.once`) — the terminal reply then lands as a duplicate-safe fallback on the same thread.
|
|
10
10
|
|
|
11
11
|
Offline: `--reply-to <channel>:<threadId>` prints the would-be reply on the terminal.
|
|
@@ -10,7 +10,7 @@ _Source of truth: workflows-spec 05 §5.17 (D19-r1/r2). This page is the develop
|
|
|
10
10
|
|
|
11
11
|
`credentialsRef` is a connection id **or a key you declare under `connections: [{ key, integrationType }]`** — declare a key; it resolves against the owner agent's own connections on any agent at run time (see [Git credentials](./git-credentials.md)), so the definition never freezes a production id.
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
**Suspension does not release the workspace.** When your run waits for a person or a signal, its volume is left exactly as it was — it keeps holding one of the org's `maxWorkspacesPerOrg` slots until a terminal reason releases it, or, for a kept `empty` workspace, `ttlHours` expires. Releasing a workspace at suspension is not implemented yet (tracked as SA-134); until then, commit before a step suspends so your work is durable on the run branch `lua/wf-<lineageId>` independent of the volume.
|
|
14
14
|
|
|
15
15
|
**Worktree arms:** parallel arms never share a filesystem; each gets its own clone and branch and the `merge` step integrates them — put `verify` on the workspace so an agent-resolved conflict must pass your tests. `onConflict:'agent'` lets one resolver turn fix conflicts.
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lua-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.42.0-beta.1",
|
|
4
4
|
"description": "Build, test, and deploy AI agents with custom tools, webhooks, and scheduled jobs. Features LuaAgent unified configuration, streaming chat, and batch deployment.",
|
|
5
5
|
"readmeFilename": "README.md",
|
|
6
6
|
"main": "dist/api-exports.js",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"files": [
|
|
62
62
|
"dist/**/*",
|
|
63
63
|
"template/**/*",
|
|
64
|
+
"template-web-app/**/*",
|
|
64
65
|
"docs/**/*",
|
|
65
66
|
"scripts/**/*",
|
|
66
67
|
"README.md",
|
package/template/package.json
CHANGED
package/template/tsconfig.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineWebApp, route } from 'lua-cli';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* __APP_NAME__ — a Lua web app.
|
|
6
|
+
*
|
|
7
|
+
* Pages: the Vite project in ./web (runs in the browser, inside Lua Workspace
|
|
8
|
+
* and the admin console). Routes: the handlers below (run in the Lua sandbox
|
|
9
|
+
* with the signed-in person as `auth`; Data, env() and fetch are available).
|
|
10
|
+
*
|
|
11
|
+
* lua apps dev __APP_NAME__ pages with hot reload, routes run locally as you
|
|
12
|
+
* lua test webapp --name __APP_NAME__ --route 'GET /hello'
|
|
13
|
+
* lua push webapp --name __APP_NAME__
|
|
14
|
+
*/
|
|
15
|
+
export default defineWebApp({
|
|
16
|
+
name: '__APP_NAME__',
|
|
17
|
+
description: 'A Lua web app',
|
|
18
|
+
pages: {
|
|
19
|
+
root: './web',
|
|
20
|
+
nav: [{ path: '/', label: 'Overview' }],
|
|
21
|
+
},
|
|
22
|
+
routes: {
|
|
23
|
+
'GET /hello': route({
|
|
24
|
+
description: 'Greets the signed-in person',
|
|
25
|
+
responses: { 200: z.object({ message: z.string() }) },
|
|
26
|
+
handler: async ({ auth }) => ({ message: `Hello, ${auth.name ?? auth.email ?? auth.userId}` }),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>__APP_NAME__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "vite build",
|
|
7
|
+
"typecheck": "tsc --noEmit"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@base-ui/react": "~1.8.0",
|
|
11
|
+
"@lua-ai-global/ui": "~0.2.0",
|
|
12
|
+
"@phosphor-icons/react": "~2.1.10",
|
|
13
|
+
"react": "~19.3.0",
|
|
14
|
+
"react-dom": "~19.3.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@tailwindcss/vite": "~4.3.3",
|
|
18
|
+
"@types/react": "~19.3.0",
|
|
19
|
+
"@types/react-dom": "~19.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "~6.1.1",
|
|
21
|
+
"shadcn": "~3.8.5",
|
|
22
|
+
"tailwindcss": "~4.3.3",
|
|
23
|
+
"tw-animate-css": "~1.4.0",
|
|
24
|
+
"typescript": "~5.9.3",
|
|
25
|
+
"vite": "~8.3.1"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Button } from '@lua-ai-global/ui/button';
|
|
3
|
+
import { Card } from '@lua-ai-global/ui/card';
|
|
4
|
+
import { lua } from './lua/client';
|
|
5
|
+
|
|
6
|
+
export function App() {
|
|
7
|
+
const [message, setMessage] = useState<string>();
|
|
8
|
+
const [error, setError] = useState<string>();
|
|
9
|
+
|
|
10
|
+
const load = () => {
|
|
11
|
+
setError(undefined);
|
|
12
|
+
lua
|
|
13
|
+
.api<{ message: string }>('GET', '/hello')
|
|
14
|
+
.then((r) => setMessage(r.message))
|
|
15
|
+
.catch((e: Error) => setError(e.message));
|
|
16
|
+
};
|
|
17
|
+
useEffect(load, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<main className="min-h-screen bg-background p-6 text-foreground">
|
|
21
|
+
<Card className="mx-auto max-w-md space-y-4 p-6">
|
|
22
|
+
<h1 className="text-lg font-semibold">{lua.appName}</h1>
|
|
23
|
+
<p className="text-muted-foreground">{error ?? message ?? 'Loading…'}</p>
|
|
24
|
+
<Button onClick={load}>Say hello again</Button>
|
|
25
|
+
</Card>
|
|
26
|
+
</main>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lua app client — the page side of a Lua web app.
|
|
3
|
+
*
|
|
4
|
+
* - Session: the shell opens this app in an iframe with a single-use handoff
|
|
5
|
+
* code in the URL fragment (`#code=…`). The client exchanges it with lua-auth
|
|
6
|
+
* for a Firebase custom token, signs in (identitytoolkit) and keeps the ID
|
|
7
|
+
* token in memory only, refreshing it (securetoken) before it expires. No
|
|
8
|
+
* cookie, no localStorage: a reload asks the shell for a new code.
|
|
9
|
+
* - Bridge (protocol v1): posts `lua:ready` once loaded, takes `lua:init`
|
|
10
|
+
* (theme, density, locale, path) from the host, posts `lua:auth:expired`
|
|
11
|
+
* when the session cannot be renewed so the shell re-issues a code. Messages
|
|
12
|
+
* go only to, and are only accepted from, the shells the gateway lists in
|
|
13
|
+
* the bootstrap (`hostOrigins`, the same list as the CSP's frame-ancestors).
|
|
14
|
+
* - `api(method, path, body?)`: calls a route of this app through the gateway
|
|
15
|
+
* (`/_lua/api/<path>`) with the session. A 401 renews the token once and
|
|
16
|
+
* retries; only a second 401 ends the session.
|
|
17
|
+
*
|
|
18
|
+
* Under `lua apps dev` the bootstrap says `dev: true`: there is no handoff and
|
|
19
|
+
* the dev server runs routes as the developer.
|
|
20
|
+
*
|
|
21
|
+
* Generated by `lua apps new`; yours to edit. (A packaged `@lua-ai-global/app-client`
|
|
22
|
+
* replaces this file later.)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface LuaBootstrap {
|
|
26
|
+
v: 1;
|
|
27
|
+
app: { id: string; name: string; version: string; deployment: number; origin: string };
|
|
28
|
+
apiBase: string;
|
|
29
|
+
manifest: string;
|
|
30
|
+
auth: { origin: string; exchangePath: string; audience: string; firebaseApiKey?: string };
|
|
31
|
+
embedded: boolean;
|
|
32
|
+
/** The shells allowed to frame this app; the bridge talks to these only. */
|
|
33
|
+
hostOrigins?: string[];
|
|
34
|
+
theme: 'light' | 'dark';
|
|
35
|
+
density: 'compact' | 'comfortable';
|
|
36
|
+
preview: boolean;
|
|
37
|
+
/** Set by `lua apps dev` only. */
|
|
38
|
+
dev?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LuaInit {
|
|
42
|
+
v: 1;
|
|
43
|
+
theme?: 'light' | 'dark';
|
|
44
|
+
density?: 'compact' | 'comfortable';
|
|
45
|
+
locale?: string;
|
|
46
|
+
path?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
declare global {
|
|
50
|
+
interface Window {
|
|
51
|
+
__LUA__?: LuaBootstrap;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BRIDGE_VERSION = 1;
|
|
56
|
+
const IDENTITY = 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken';
|
|
57
|
+
const SECURE_TOKEN = 'https://securetoken.googleapis.com/v1/token';
|
|
58
|
+
/** Renew this long before the ID token expires. */
|
|
59
|
+
const REFRESH_MARGIN_MS = 5 * 60_000;
|
|
60
|
+
|
|
61
|
+
export class LuaApiError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
readonly status: number,
|
|
64
|
+
readonly code: string | undefined,
|
|
65
|
+
message: string,
|
|
66
|
+
readonly executionId?: string
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'LuaApiError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Tokens {
|
|
74
|
+
idToken: string;
|
|
75
|
+
refreshToken: string;
|
|
76
|
+
expiresInS: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
|
|
80
|
+
|
|
81
|
+
/** A token response is only a session when every field is there; anything else is "no session". */
|
|
82
|
+
function tokensFrom(idToken: unknown, refreshToken: unknown, expiresIn: unknown): Tokens | null {
|
|
83
|
+
const expiresInS = Number(expiresIn);
|
|
84
|
+
if (!nonEmpty(idToken) || !nonEmpty(refreshToken) || !Number.isFinite(expiresInS) || expiresInS <= 0) return null;
|
|
85
|
+
return { idToken, refreshToken, expiresInS };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function jsonOrNull(response: Response | undefined): Promise<Record<string, unknown> | null> {
|
|
89
|
+
if (!response?.ok) return null;
|
|
90
|
+
try {
|
|
91
|
+
const body = await response.json();
|
|
92
|
+
return body && typeof body === 'object' ? (body as Record<string, unknown>) : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class LuaAppClient {
|
|
99
|
+
private readonly boot: LuaBootstrap | undefined = window.__LUA__;
|
|
100
|
+
private idToken: string | undefined;
|
|
101
|
+
private refreshToken: string | undefined;
|
|
102
|
+
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
private renewing: Promise<boolean> | undefined;
|
|
104
|
+
private session: Promise<void> | undefined;
|
|
105
|
+
private readonly initListeners: Array<(init: LuaInit) => void> = [];
|
|
106
|
+
|
|
107
|
+
get appName(): string {
|
|
108
|
+
return this.boot?.app.name ?? document.title;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get theme(): 'light' | 'dark' {
|
|
112
|
+
return this.boot?.theme ?? 'dark';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onInit(listener: (init: LuaInit) => void): void {
|
|
116
|
+
this.initListeners.push(listener);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Take the handoff code, open the session, tell the host we are ready. */
|
|
120
|
+
start(): Promise<void> {
|
|
121
|
+
if (!this.session) {
|
|
122
|
+
window.addEventListener('message', (event) => {
|
|
123
|
+
// Only the shell that framed us, from an origin the gateway allows, speaks the bridge.
|
|
124
|
+
if (event.source !== window.parent || !this.hostOrigins().includes(event.origin)) return;
|
|
125
|
+
if (event.data?.type !== 'lua:init') return;
|
|
126
|
+
for (const listener of this.initListeners) listener(event.data as LuaInit);
|
|
127
|
+
});
|
|
128
|
+
this.session = this.openSession().finally(() => this.post({ type: 'lua:ready', v: BRIDGE_VERSION }));
|
|
129
|
+
}
|
|
130
|
+
return this.session;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
134
|
+
await this.start();
|
|
135
|
+
let response = await this.send(method, path, body);
|
|
136
|
+
// A 401 usually means the ID token lapsed between refreshes: renew once and retry once.
|
|
137
|
+
if (response.status === 401 && (await this.renew())) response = await this.send(method, path, body);
|
|
138
|
+
if (response.status === 401) this.expired();
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
let data: any;
|
|
141
|
+
try {
|
|
142
|
+
data = text ? JSON.parse(text) : undefined;
|
|
143
|
+
} catch {
|
|
144
|
+
data = text;
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new LuaApiError(
|
|
148
|
+
response.status,
|
|
149
|
+
data?.code,
|
|
150
|
+
data?.message ?? response.statusText,
|
|
151
|
+
response.headers.get('x-lua-execution-id') ?? undefined
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return data as T;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private send(method: string, path: string, body?: unknown): Promise<Response> {
|
|
158
|
+
const base = this.boot?.apiBase ?? '/_lua/api';
|
|
159
|
+
const headers: Record<string, string> = { accept: 'application/json' };
|
|
160
|
+
if (this.idToken) headers.authorization = `Bearer ${this.idToken}`;
|
|
161
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
162
|
+
return fetch(`${base}${path.startsWith('/') ? path : `/${path}`}`, {
|
|
163
|
+
method,
|
|
164
|
+
headers,
|
|
165
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async openSession(): Promise<void> {
|
|
170
|
+
const boot = this.boot;
|
|
171
|
+
if (!boot || boot.dev) return; // `lua apps dev`: the dev server is the caller's session.
|
|
172
|
+
const code = new URLSearchParams(location.hash.slice(1)).get('code');
|
|
173
|
+
// The code is single-use: drop it from the URL before anything can copy it.
|
|
174
|
+
if (code) history.replaceState(null, '', location.pathname + location.search);
|
|
175
|
+
if (!code || !boot.auth.firebaseApiKey) return this.expired();
|
|
176
|
+
|
|
177
|
+
const exchanged = await jsonOrNull(
|
|
178
|
+
await fetch(`${boot.auth.origin}${boot.auth.exchangePath}`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'content-type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ code, audience: boot.auth.audience }),
|
|
182
|
+
}).catch(() => undefined)
|
|
183
|
+
);
|
|
184
|
+
if (!nonEmpty(exchanged?.token)) return this.expired();
|
|
185
|
+
|
|
186
|
+
const signedIn = await jsonOrNull(
|
|
187
|
+
await fetch(`${IDENTITY}?key=${encodeURIComponent(boot.auth.firebaseApiKey)}`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json' },
|
|
190
|
+
body: JSON.stringify({ token: exchanged!.token, returnSecureToken: true }),
|
|
191
|
+
}).catch(() => undefined)
|
|
192
|
+
);
|
|
193
|
+
const tokens = signedIn && tokensFrom(signedIn.idToken, signedIn.refreshToken, signedIn.expiresIn);
|
|
194
|
+
if (!tokens) return this.expired();
|
|
195
|
+
this.keep(tokens);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private keep(tokens: Tokens): void {
|
|
199
|
+
this.idToken = tokens.idToken;
|
|
200
|
+
this.refreshToken = tokens.refreshToken;
|
|
201
|
+
clearTimeout(this.refreshTimer);
|
|
202
|
+
this.refreshTimer = setTimeout(
|
|
203
|
+
() => void this.renew().then((ok) => ok || this.expired()),
|
|
204
|
+
Math.max(tokens.expiresInS * 1000 - REFRESH_MARGIN_MS, 30_000)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Swap the refresh token for a new ID token. One renewal at a time; `false` when it cannot be done. */
|
|
209
|
+
private renew(): Promise<boolean> {
|
|
210
|
+
if (!this.renewing) {
|
|
211
|
+
this.renewing = this.refreshOnce().finally(() => (this.renewing = undefined));
|
|
212
|
+
}
|
|
213
|
+
return this.renewing;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async refreshOnce(): Promise<boolean> {
|
|
217
|
+
const key = this.boot?.auth.firebaseApiKey;
|
|
218
|
+
if (!key || !this.refreshToken) return false;
|
|
219
|
+
const r = await jsonOrNull(
|
|
220
|
+
await fetch(`${SECURE_TOKEN}?key=${encodeURIComponent(key)}`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
223
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.refreshToken }),
|
|
224
|
+
}).catch(() => undefined)
|
|
225
|
+
);
|
|
226
|
+
const tokens = r && tokensFrom(r.id_token, r.refresh_token, r.expires_in);
|
|
227
|
+
if (!tokens) return false;
|
|
228
|
+
this.keep(tokens);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The session is gone: the shell answers with a fresh handoff code. */
|
|
233
|
+
private expired(): void {
|
|
234
|
+
this.idToken = undefined;
|
|
235
|
+
this.refreshToken = undefined;
|
|
236
|
+
clearTimeout(this.refreshTimer);
|
|
237
|
+
this.post({ type: 'lua:auth:expired', v: BRIDGE_VERSION });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private hostOrigins(): string[] {
|
|
241
|
+
return this.boot?.hostOrigins ?? [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One post per allowed shell origin: the browser delivers it only when the
|
|
246
|
+
* framing window really has that origin, so no other page ever receives it.
|
|
247
|
+
*/
|
|
248
|
+
private post(message: { type: string; v: number }): void {
|
|
249
|
+
if (window.parent === window) return;
|
|
250
|
+
for (const origin of this.hostOrigins()) window.parent.postMessage(message, origin);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const lua = new LuaAppClient();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { App } from './App';
|
|
4
|
+
import { lua } from './lua/client';
|
|
5
|
+
import './index.css';
|
|
6
|
+
|
|
7
|
+
// Theme follows the shell: first from the bootstrap, then from the host's `init`.
|
|
8
|
+
document.documentElement.classList.toggle('dark', lua.theme === 'dark');
|
|
9
|
+
lua.onInit((init) => document.documentElement.classList.toggle('dark', init.theme === 'dark'));
|
|
10
|
+
|
|
11
|
+
createRoot(document.getElementById('root')!).render(
|
|
12
|
+
<StrictMode>
|
|
13
|
+
<App />
|
|
14
|
+
</StrictMode>
|
|
15
|
+
);
|
|
16
|
+
void lua.start();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"isolatedModules": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
|
|
5
|
+
// `lua push webapp` builds with this config and pins only what the Lua gateway
|
|
6
|
+
// needs on top of it (base '/', assets under assets/, the CSP nonce).
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react(), tailwindcss()],
|
|
9
|
+
});
|