@lessly/app-dev 0.1.0
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/README.md +48 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +472 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @lessly/app-dev
|
|
2
|
+
|
|
3
|
+
Vite plugin for local development of Lessly Apps. It proxies `/lessly-api/*` to the
|
|
4
|
+
Lessly REST API with an automatically-injected bearer token, exposes the dev
|
|
5
|
+
`import.meta.env` values your App needs, and serves the Module-Federation manifest
|
|
6
|
+
at the origin root.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// vite.config.ts
|
|
12
|
+
import { defineConfig } from 'vite';
|
|
13
|
+
import { lesslyAppDev } from '@lessly/app-dev';
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
plugins: [lesslyAppDev()],
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Environment
|
|
21
|
+
|
|
22
|
+
Set these in `.env.local` (they are read from your project's env files):
|
|
23
|
+
|
|
24
|
+
| Variable | Required | Purpose |
|
|
25
|
+
| ------------------- | -------- | -------------------------------------------------------------- |
|
|
26
|
+
| `VITE_PRODUCT_ID` | standalone only | The Lessly product your App runs under. Required for standalone dev; optional when composed under the shell (`dev:federation`/`dev:shell`), which supplies it at runtime. The plugin **warns and continues** if unset — it never fails the build. |
|
|
27
|
+
| `VITE_API_URL` | no | Override the REST API base (defaults per `LESSLY_ENV`). |
|
|
28
|
+
| `VITE_PRODUCT_SLUG` | no | Slug used in the shell dev-remote hint. |
|
|
29
|
+
| `LESSLY_ENV` | no | `staging` (default) or `prod`. |
|
|
30
|
+
| `DEV_API_KEY` | no | Headless auth (CI): exchanged for a short-lived token. Never exposed to the client bundle. |
|
|
31
|
+
|
|
32
|
+
## Authentication
|
|
33
|
+
|
|
34
|
+
The plugin injects `Authorization: Bearer <token>` on every `/lessly-api/*` request
|
|
35
|
+
using, in order: a valid cached token → a stored token in `~/.lessly/credentials.json`
|
|
36
|
+
→ a refreshed token → `DEV_API_KEY` exchange → interactive device login. Token
|
|
37
|
+
refresh is coordinated across processes with a file lock beside the credentials file.
|
|
38
|
+
|
|
39
|
+
## Scope: REST-only (v1)
|
|
40
|
+
|
|
41
|
+
**This plugin proxies HTTP REST calls only. WebSocket upgrades on `/lessly-api/*`
|
|
42
|
+
are intentionally not proxied in v1** — Lessly Apps use the REST API for local
|
|
43
|
+
development, and adding WS proxying (and its auth/reconnect semantics) is deferred
|
|
44
|
+
until a concrete need exists. If you require realtime transport locally, connect to
|
|
45
|
+
the target service directly rather than through the dev proxy.
|
|
46
|
+
|
|
47
|
+
Cross-site requests to the dev proxy (`Sec-Fetch-Site: cross-site`) are rejected as a
|
|
48
|
+
lightweight drive-by-CSRF defense.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
type LesslyEnvName = 'staging' | 'prod';
|
|
4
|
+
interface LesslyAppDevOptions {
|
|
5
|
+
/** Override env; defaults to LESSLY_ENV or 'staging'. */
|
|
6
|
+
env?: LesslyEnvName;
|
|
7
|
+
/** Override REST API base; defaults to VITE_API_URL or the env default. */
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
/** Vite dev-server proxy path. Fixed contract C1. */
|
|
10
|
+
proxyPath?: '/lessly-api';
|
|
11
|
+
}
|
|
12
|
+
interface ResolvedEnv {
|
|
13
|
+
name: LesslyEnvName;
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
authServer: string;
|
|
16
|
+
profile: LesslyEnvName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare function resolveShellDevOrigin(processEnv?: Record<string, string | undefined>): string;
|
|
20
|
+
|
|
21
|
+
interface LesslyAppDevDeps {
|
|
22
|
+
/** Override the home dir used to locate ~/.lessly (tests). */
|
|
23
|
+
homedir?: () => string;
|
|
24
|
+
/** Override token-provider construction (tests / advanced embedding). */
|
|
25
|
+
createToken?: (a: {
|
|
26
|
+
env: ResolvedEnv;
|
|
27
|
+
apiKeyEnv?: string;
|
|
28
|
+
}) => {
|
|
29
|
+
get(): Promise<string>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
declare function lesslyAppDev(options?: LesslyAppDevOptions, deps?: LesslyAppDevDeps): Plugin;
|
|
33
|
+
|
|
34
|
+
export { type LesslyAppDevDeps, type LesslyAppDevOptions, type LesslyEnvName, type ResolvedEnv, lesslyAppDev, resolveShellDevOrigin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { loadEnv } from "vite";
|
|
3
|
+
|
|
4
|
+
// src/env.ts
|
|
5
|
+
var HOSTS = {
|
|
6
|
+
staging: { apiUrl: "https://api.lessly.dev", authServer: "https://auth.lessly.dev" },
|
|
7
|
+
prod: { apiUrl: "https://api.lessly.com", authServer: "https://auth.lessly.com" }
|
|
8
|
+
};
|
|
9
|
+
function resolveEnv(input = {}) {
|
|
10
|
+
const pe = input.processEnv ?? process.env;
|
|
11
|
+
const name = input.env ?? (pe.LESSLY_ENV === "prod" ? "prod" : "staging");
|
|
12
|
+
const hosts = HOSTS[name];
|
|
13
|
+
return {
|
|
14
|
+
name,
|
|
15
|
+
profile: name,
|
|
16
|
+
apiUrl: input.apiUrl ?? pe.VITE_API_URL ?? hosts.apiUrl,
|
|
17
|
+
authServer: hosts.authServer
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/proxy.ts
|
|
22
|
+
function buildProxyConfig(args) {
|
|
23
|
+
const prefix = args.proxyPath;
|
|
24
|
+
const key = `^${prefix}(?:[/?]|$)`;
|
|
25
|
+
return {
|
|
26
|
+
[key]: {
|
|
27
|
+
target: args.apiUrl,
|
|
28
|
+
changeOrigin: true,
|
|
29
|
+
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ""),
|
|
30
|
+
// Bearer injection happens in the awaited auth middleware (see auth-middleware.ts);
|
|
31
|
+
// Vite forwards incoming request headers to the upstream. Here we only surface a
|
|
32
|
+
// one-time hint when the upstream rejects our token.
|
|
33
|
+
configure: (proxy) => {
|
|
34
|
+
let warned = false;
|
|
35
|
+
proxy.on("proxyRes", (proxyRes) => {
|
|
36
|
+
if (proxyRes.statusCode === 401 && !warned) {
|
|
37
|
+
warned = true;
|
|
38
|
+
console.warn(
|
|
39
|
+
" [lessly-app-dev] upstream returned 401 Unauthorized. Your session may be revoked server-side. Delete ~/.lessly/credentials.json and re-run dev to re-login."
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/config-inject.ts
|
|
49
|
+
function productIdMissingWarning() {
|
|
50
|
+
return [
|
|
51
|
+
" [lessly-app-dev] WARNING: VITE_PRODUCT_ID is not set.",
|
|
52
|
+
" \u2022 Set it in .env.local: VITE_PRODUCT_ID=<your product id>",
|
|
53
|
+
" \u2022 Find the id in the Lessly workspace URL (\u2026/products/<id>) or ask your workspace admin.",
|
|
54
|
+
" \u2022 Safe to ignore in composed dev (dev:federation / dev:shell): the shell supplies the",
|
|
55
|
+
" product id at runtime. Standalone without it renders a BootPage."
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
function buildDefine(args) {
|
|
59
|
+
const define = {
|
|
60
|
+
"import.meta.env.VITE_LESSLY_BASE_URL": JSON.stringify(args.proxyPath)
|
|
61
|
+
};
|
|
62
|
+
if (args.productId) define["import.meta.env.VITE_PRODUCT_ID"] = JSON.stringify(args.productId);
|
|
63
|
+
return define;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/credentials-store.ts
|
|
67
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
68
|
+
import { homedir } from "os";
|
|
69
|
+
import { join } from "path";
|
|
70
|
+
function credentialsPath(home = homedir()) {
|
|
71
|
+
return join(home, ".lessly", "credentials.json");
|
|
72
|
+
}
|
|
73
|
+
function readCredentials(home = homedir()) {
|
|
74
|
+
const path = credentialsPath(home);
|
|
75
|
+
if (!existsSync(path)) return { tokens: {} };
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = readFileSync(path, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return { tokens: {} };
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`~/.lessly/credentials.json is corrupted and could not be parsed. Delete it (rm ${path}) and run dev again to re-authenticate.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (!parsed || typeof parsed !== "object") return { tokens: {} };
|
|
91
|
+
const creds = parsed;
|
|
92
|
+
creds.tokens ??= {};
|
|
93
|
+
return creds;
|
|
94
|
+
}
|
|
95
|
+
function loadAccessToken(home, profile) {
|
|
96
|
+
return readCredentials(home).tokens[profile];
|
|
97
|
+
}
|
|
98
|
+
function loadRefreshToken(home, profile) {
|
|
99
|
+
return readCredentials(home).refresh?.[profile];
|
|
100
|
+
}
|
|
101
|
+
function saveTokens(home, profile, t) {
|
|
102
|
+
const creds = readCredentials(home);
|
|
103
|
+
creds.tokens[profile] = t.accessToken;
|
|
104
|
+
if (t.refreshToken) (creds.refresh ??= {})[profile] = t.refreshToken;
|
|
105
|
+
mkdirSync(join(home, ".lessly"), { recursive: true });
|
|
106
|
+
const path = credentialsPath(home);
|
|
107
|
+
writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
108
|
+
chmodSync(path, 384);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/jwt.ts
|
|
112
|
+
function decodeJwtPayload(token) {
|
|
113
|
+
try {
|
|
114
|
+
const part = token.split(".")[1];
|
|
115
|
+
if (!part) return void 0;
|
|
116
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isExpired(token, now = Date.now(), skewSeconds = 60) {
|
|
122
|
+
const p = decodeJwtPayload(token);
|
|
123
|
+
const exp = typeof p?.exp === "number" ? p.exp : void 0;
|
|
124
|
+
if (exp === void 0) return true;
|
|
125
|
+
return (exp - skewSeconds) * 1e3 <= now;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/refresh-lock.ts
|
|
129
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeSync } from "fs";
|
|
130
|
+
import { hostname } from "os";
|
|
131
|
+
import { join as join2 } from "path";
|
|
132
|
+
import { randomUUID } from "crypto";
|
|
133
|
+
function defaultIsAlive(holder) {
|
|
134
|
+
if (!holder.host || holder.host !== hostname()) return true;
|
|
135
|
+
if (typeof holder.pid !== "number") return true;
|
|
136
|
+
try {
|
|
137
|
+
process.kill(holder.pid, 0);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return e.code === "EPERM";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function readHolder(lockPath) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(readFileSync2(lockPath, "utf8"));
|
|
146
|
+
if (parsed && typeof parsed === "object" && typeof parsed.owner === "string") {
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
async function withRefreshLock(home, fn, opts = {}) {
|
|
154
|
+
const now = opts.now ?? Date.now;
|
|
155
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
156
|
+
const staleMs = opts.staleMs ?? 3e4;
|
|
157
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
158
|
+
const pollMs = opts.pollMs ?? 100;
|
|
159
|
+
const isAlive = opts.isAlive ?? defaultIsAlive;
|
|
160
|
+
const dir = join2(home, ".lessly");
|
|
161
|
+
mkdirSync2(dir, { recursive: true });
|
|
162
|
+
const lockPath = join2(dir, "refresh.lock");
|
|
163
|
+
const deadline = now() + timeoutMs;
|
|
164
|
+
const owner = randomUUID();
|
|
165
|
+
const meta = JSON.stringify({ owner, pid: process.pid, host: hostname() });
|
|
166
|
+
for (; ; ) {
|
|
167
|
+
try {
|
|
168
|
+
const fd = openSync(lockPath, "wx");
|
|
169
|
+
writeSync(fd, meta);
|
|
170
|
+
closeSync(fd);
|
|
171
|
+
break;
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e.code !== "EEXIST") throw e;
|
|
174
|
+
let age;
|
|
175
|
+
try {
|
|
176
|
+
age = now() - statSync(lockPath).mtimeMs;
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (age > staleMs) {
|
|
181
|
+
const holder = readHolder(lockPath);
|
|
182
|
+
const dead = holder ? !isAlive(holder) : true;
|
|
183
|
+
if (dead) {
|
|
184
|
+
const claim = `${lockPath}.reclaim.${owner}`;
|
|
185
|
+
try {
|
|
186
|
+
renameSync(lockPath, claim);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const claimed = readHolder(claim);
|
|
191
|
+
if (claimed && isAlive(claimed)) {
|
|
192
|
+
try {
|
|
193
|
+
const fd = openSync(lockPath, "wx");
|
|
194
|
+
writeSync(fd, JSON.stringify(claimed));
|
|
195
|
+
closeSync(fd);
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
unlinkSync(claim);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
try {
|
|
204
|
+
unlinkSync(claim);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (now() >= deadline) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`timed out waiting for the Lessly refresh lock; another process is still refreshing the token. Retry, or if it is stuck delete ${lockPath}.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
await sleep(pollMs);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return await fn();
|
|
221
|
+
} finally {
|
|
222
|
+
try {
|
|
223
|
+
if (readHolder(lockPath)?.owner === owner) unlinkSync(lockPath);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/token.ts
|
|
230
|
+
function createTokenProvider(args) {
|
|
231
|
+
let inflight = null;
|
|
232
|
+
let cached = null;
|
|
233
|
+
async function resolve() {
|
|
234
|
+
if (cached && !isExpired(cached)) return cached;
|
|
235
|
+
const stored = loadAccessToken(args.home, args.profile);
|
|
236
|
+
if (stored && !isExpired(stored)) return stored;
|
|
237
|
+
const refreshTok = loadRefreshToken(args.home, args.profile);
|
|
238
|
+
if (refreshTok) {
|
|
239
|
+
const withLock = args.withLock ?? withRefreshLock;
|
|
240
|
+
try {
|
|
241
|
+
return cached = await withLock(args.home, async () => {
|
|
242
|
+
const fresh = loadAccessToken(args.home, args.profile);
|
|
243
|
+
if (fresh && !isExpired(fresh)) return fresh;
|
|
244
|
+
const rt = loadRefreshToken(args.home, args.profile) ?? refreshTok;
|
|
245
|
+
return await args.refresh(rt);
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (args.apiKey) return cached = await args.apiKey();
|
|
251
|
+
return cached = await args.device();
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
get() {
|
|
255
|
+
return inflight ??= resolve().finally(() => {
|
|
256
|
+
inflight = null;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/refresh.ts
|
|
263
|
+
var CLIENT_ID = "lessly-cli";
|
|
264
|
+
async function refreshAccessToken(args) {
|
|
265
|
+
const res = await args.post(`${args.authServer}/oauth/token`, {
|
|
266
|
+
grant_type: "refresh_token",
|
|
267
|
+
refresh_token: args.refreshToken,
|
|
268
|
+
client_id: CLIENT_ID
|
|
269
|
+
});
|
|
270
|
+
if (res.status < 200 || res.status >= 300 || !res.body?.access_token) {
|
|
271
|
+
const err = res.body?.error ?? `HTTP ${res.status}`;
|
|
272
|
+
throw new Error(`token refresh failed: ${err}`);
|
|
273
|
+
}
|
|
274
|
+
saveTokens(args.home, args.profile, {
|
|
275
|
+
accessToken: res.body.access_token,
|
|
276
|
+
refreshToken: res.body.refresh_token ?? args.refreshToken
|
|
277
|
+
});
|
|
278
|
+
return res.body.access_token;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/device.ts
|
|
282
|
+
var CLIENT_ID2 = "lessly-cli";
|
|
283
|
+
var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
284
|
+
async function runDeviceLogin(args) {
|
|
285
|
+
const { authServer, deps, io } = args;
|
|
286
|
+
const authRes = await deps.post(`${authServer}/oauth/device_authorization`, { client_id: CLIENT_ID2 });
|
|
287
|
+
if (authRes.status < 200 || authRes.status >= 300) {
|
|
288
|
+
throw new Error(`device authorization failed (${authRes.status})`);
|
|
289
|
+
}
|
|
290
|
+
const auth = authRes.body;
|
|
291
|
+
io.print(`
|
|
292
|
+
To sign in, open ${auth.verification_uri} and enter code: ${auth.user_code}`);
|
|
293
|
+
if (auth.verification_uri_complete) io.print(` Or open: ${auth.verification_uri_complete}`);
|
|
294
|
+
io.print(" Waiting for authorization\u2026\n");
|
|
295
|
+
let interval = Math.max(1, auth.interval || 5);
|
|
296
|
+
const expiresIn = Number.isFinite(auth.expires_in) && auth.expires_in > 0 ? auth.expires_in : 600;
|
|
297
|
+
const deadline = deps.now() + expiresIn * 1e3;
|
|
298
|
+
for (; ; ) {
|
|
299
|
+
await deps.sleep(interval * 1e3);
|
|
300
|
+
if (deps.now() >= deadline) throw new Error("device login expired; run dev again");
|
|
301
|
+
const res = await deps.post(`${authServer}/oauth/token`, {
|
|
302
|
+
grant_type: DEVICE_GRANT,
|
|
303
|
+
device_code: auth.device_code,
|
|
304
|
+
client_id: CLIENT_ID2
|
|
305
|
+
});
|
|
306
|
+
if (res.status >= 200 && res.status < 300 && res.body?.access_token) {
|
|
307
|
+
saveTokens(args.home, args.profile, { accessToken: res.body.access_token, refreshToken: res.body.refresh_token });
|
|
308
|
+
return res.body.access_token;
|
|
309
|
+
}
|
|
310
|
+
const error = res.body?.error;
|
|
311
|
+
if (error === "authorization_pending") continue;
|
|
312
|
+
if (error === "slow_down") {
|
|
313
|
+
interval += 5;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (error === "expired_token") throw new Error("device login expired; run dev again");
|
|
317
|
+
if (error === "access_denied") throw new Error("device login was denied");
|
|
318
|
+
throw new Error(`device login failed: ${error ?? `HTTP ${res.status}`}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function defaultDeviceDeps() {
|
|
322
|
+
return {
|
|
323
|
+
post: async (url, form) => {
|
|
324
|
+
const res = await fetch(url, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
327
|
+
body: new URLSearchParams(form).toString()
|
|
328
|
+
});
|
|
329
|
+
let body;
|
|
330
|
+
try {
|
|
331
|
+
body = await res.json();
|
|
332
|
+
} catch {
|
|
333
|
+
body = void 0;
|
|
334
|
+
}
|
|
335
|
+
return { status: res.status, body };
|
|
336
|
+
},
|
|
337
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
338
|
+
now: () => Date.now()
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/api-key.ts
|
|
343
|
+
async function resolveApiKeyToken(args) {
|
|
344
|
+
if (!args.apiKey) throw new Error("DEV_API_KEY is empty");
|
|
345
|
+
const fetchFn = args.fetchFn ?? fetch;
|
|
346
|
+
const base = args.authServer.replace(/\/+$/, "");
|
|
347
|
+
const apiBase = base.endsWith("/api/v1") ? base : `${base}/api/v1`;
|
|
348
|
+
const res = await fetchFn(`${apiBase}/api-keys/exchange`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${args.apiKey}` },
|
|
351
|
+
body: JSON.stringify({})
|
|
352
|
+
});
|
|
353
|
+
if (!res.ok) {
|
|
354
|
+
let body = {};
|
|
355
|
+
try {
|
|
356
|
+
body = await res.json();
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`DEV_API_KEY exchange failed (${res.status}): ${body.error ?? body.message ?? "unknown error"}`);
|
|
360
|
+
}
|
|
361
|
+
const envelope = await res.json();
|
|
362
|
+
return envelope.data.idToken;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/composed-dev.ts
|
|
366
|
+
function resolveShellDevOrigin(processEnv = process.env) {
|
|
367
|
+
return processEnv.SHELL_DEV_ORIGIN ?? "http://localhost:5173";
|
|
368
|
+
}
|
|
369
|
+
function shellOverrideMessage(slug, origin) {
|
|
370
|
+
return [
|
|
371
|
+
" To load this App inside the Workspace shell dev server, run this in the",
|
|
372
|
+
" shell browser console (C3 contract):",
|
|
373
|
+
"",
|
|
374
|
+
` localStorage.setItem('lessly:dev-remote', '${slug}=${origin}')`,
|
|
375
|
+
"",
|
|
376
|
+
" then reload the shell."
|
|
377
|
+
].join("\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/mf-manifest.ts
|
|
381
|
+
var MANIFEST_PATH = "/mf-manifest.json";
|
|
382
|
+
function mfManifestMiddleware(getBase) {
|
|
383
|
+
return (req, _res, next) => {
|
|
384
|
+
const [path, query] = (req.url ?? "").split("?");
|
|
385
|
+
if (req.method !== "GET" || path !== MANIFEST_PATH) {
|
|
386
|
+
next();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const base = getBase().replace(/\/+$/, "");
|
|
390
|
+
if (base) {
|
|
391
|
+
req.url = `${base}${MANIFEST_PATH}${query ? `?${query}` : ""}`;
|
|
392
|
+
}
|
|
393
|
+
next();
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/auth-middleware.ts
|
|
398
|
+
function createAuthMiddleware(args) {
|
|
399
|
+
const boundary = new RegExp(`^${args.proxyPath}(?:[/?]|$)`);
|
|
400
|
+
return (req, res, next) => {
|
|
401
|
+
const url = req.url ?? "";
|
|
402
|
+
if (!boundary.test(url)) return next();
|
|
403
|
+
if (req.headers["sec-fetch-site"] === "cross-site") {
|
|
404
|
+
res.statusCode = 403;
|
|
405
|
+
res.end("cross-site requests to the Lessly dev proxy are not allowed");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
args.token.get().then(
|
|
409
|
+
(t) => {
|
|
410
|
+
req.headers.authorization = `Bearer ${t}`;
|
|
411
|
+
next();
|
|
412
|
+
},
|
|
413
|
+
(err) => next(err)
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/index.ts
|
|
419
|
+
import { homedir as homedir2 } from "os";
|
|
420
|
+
var PROXY_PATH = "/lessly-api";
|
|
421
|
+
function lesslyAppDev(options = {}, deps = {}) {
|
|
422
|
+
const homedirFn = deps.homedir ?? homedir2;
|
|
423
|
+
const devDeps = defaultDeviceDeps();
|
|
424
|
+
const post = devDeps.post;
|
|
425
|
+
let token;
|
|
426
|
+
let resolvedEnv;
|
|
427
|
+
let productId;
|
|
428
|
+
let slug = "your-app";
|
|
429
|
+
function buildToken(env, apiKeyEnv) {
|
|
430
|
+
if (deps.createToken) return deps.createToken({ env, apiKeyEnv });
|
|
431
|
+
const home = homedirFn();
|
|
432
|
+
return createTokenProvider({
|
|
433
|
+
home,
|
|
434
|
+
profile: env.profile,
|
|
435
|
+
authServer: env.authServer,
|
|
436
|
+
refresh: (rt) => refreshAccessToken({ authServer: env.authServer, refreshToken: rt, home, profile: env.profile, post }),
|
|
437
|
+
device: () => runDeviceLogin({ authServer: env.authServer, home, profile: env.profile, deps: devDeps, io: { print: (m) => console.log(m) } }),
|
|
438
|
+
apiKey: apiKeyEnv ? () => resolveApiKeyToken({ apiKey: apiKeyEnv, authServer: env.authServer }) : void 0
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
name: "lessly-app-dev",
|
|
443
|
+
config(config, envInfo) {
|
|
444
|
+
if (envInfo.command !== "serve") return void 0;
|
|
445
|
+
const root = config.root ?? process.cwd();
|
|
446
|
+
const fileEnv = loadEnv(envInfo.mode, root, "");
|
|
447
|
+
resolvedEnv = resolveEnv({ env: options.env, apiUrl: options.apiUrl, processEnv: fileEnv });
|
|
448
|
+
productId = fileEnv.VITE_PRODUCT_ID;
|
|
449
|
+
slug = fileEnv.VITE_PRODUCT_SLUG ?? "your-app";
|
|
450
|
+
token = buildToken(resolvedEnv, fileEnv.DEV_API_KEY);
|
|
451
|
+
return {
|
|
452
|
+
server: { proxy: buildProxyConfig({ apiUrl: resolvedEnv.apiUrl, proxyPath: PROXY_PATH }) },
|
|
453
|
+
define: buildDefine({ productId, proxyPath: PROXY_PATH })
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
configureServer(server) {
|
|
457
|
+
if (token) server.middlewares.use(createAuthMiddleware({ proxyPath: PROXY_PATH, token }));
|
|
458
|
+
server.middlewares.use(mfManifestMiddleware(() => server.config.base));
|
|
459
|
+
server.httpServer?.once("listening", () => {
|
|
460
|
+
const env = resolvedEnv;
|
|
461
|
+
console.log(`
|
|
462
|
+
[lessly-app-dev] proxying ${PROXY_PATH} \u2192 ${env.apiUrl} (${env.name})`);
|
|
463
|
+
if (!productId) console.warn(productIdMissingWarning());
|
|
464
|
+
console.log(shellOverrideMessage(slug, resolveShellDevOrigin()));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
export {
|
|
470
|
+
lesslyAppDev,
|
|
471
|
+
resolveShellDevOrigin
|
|
472
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lessly/app-dev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vite plugin for local development of Lessly Apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist/**"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"test": "vitest run --passWithNoTests",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": { "vite": ">=7" },
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"tsup": "^8.3.5",
|
|
18
|
+
"typescript": "~5.9.3",
|
|
19
|
+
"vite": "^7.3.0",
|
|
20
|
+
"vitest": "^4.0.18",
|
|
21
|
+
"@types/node": "^24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }
|
|
24
|
+
}
|