@ptkl/toolkit 0.8.12 → 0.9.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/bin/toolkit.js +3 -1
- package/dist/commands/forge.js +146 -21
- package/dist/lib/devHub.js +130 -0
- package/package.json +1 -1
package/dist/bin/toolkit.js
CHANGED
package/dist/commands/forge.js
CHANGED
|
@@ -2,10 +2,33 @@ import { Command } from "commander";
|
|
|
2
2
|
import { build, createServer } from 'vite';
|
|
3
3
|
import { c } from 'tar';
|
|
4
4
|
import { Writable } from "stream";
|
|
5
|
-
import { writeFileSync, readFileSync } from "fs";
|
|
5
|
+
import { writeFileSync, readFileSync, rmSync, existsSync } from "fs";
|
|
6
6
|
import axios from 'axios';
|
|
7
7
|
import Util from "../lib/util.js";
|
|
8
8
|
import { join } from 'path';
|
|
9
|
+
function formatBuildError(reason) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
if (reason.id || reason.loc) {
|
|
12
|
+
const file = reason.id || reason.loc?.file || '';
|
|
13
|
+
const loc = reason.loc ? `:${reason.loc.line}:${reason.loc.column}` : '';
|
|
14
|
+
if (file)
|
|
15
|
+
lines.push(` File: ${file}${loc}`);
|
|
16
|
+
}
|
|
17
|
+
if (reason.plugin) {
|
|
18
|
+
lines.push(` Plugin: ${reason.plugin}`);
|
|
19
|
+
}
|
|
20
|
+
if (reason.message) {
|
|
21
|
+
lines.push(` Error: ${reason.message}`);
|
|
22
|
+
}
|
|
23
|
+
if (reason.frame) {
|
|
24
|
+
lines.push('');
|
|
25
|
+
lines.push(reason.frame);
|
|
26
|
+
}
|
|
27
|
+
if (!reason.frame && reason.cause) {
|
|
28
|
+
lines.push(` Cause: ${reason.cause?.message || String(reason.cause)}`);
|
|
29
|
+
}
|
|
30
|
+
return lines.join('\n') || String(reason);
|
|
31
|
+
}
|
|
9
32
|
class ForgeCommand {
|
|
10
33
|
register() {
|
|
11
34
|
return new Command("forge")
|
|
@@ -50,6 +73,10 @@ class ForgeCommand {
|
|
|
50
73
|
process.chdir(path);
|
|
51
74
|
const module = await import(`${path}/ptkl.config.js`);
|
|
52
75
|
const { views, name, version, distPath, icon, type, label, permissions, install_permissions, runtime_permissions, entitlements, requires, scripts, ssrRenderer, } = module.default ?? {};
|
|
76
|
+
// Clean dist folder before building to remove stale files
|
|
77
|
+
if (existsSync(distPath)) {
|
|
78
|
+
rmSync(distPath, { recursive: true, force: true });
|
|
79
|
+
}
|
|
53
80
|
// Validate combined permissions limit
|
|
54
81
|
const rtPerms = runtime_permissions ?? [];
|
|
55
82
|
const ents = entitlements ?? [];
|
|
@@ -73,20 +100,27 @@ class ForgeCommand {
|
|
|
73
100
|
ssrRenderer,
|
|
74
101
|
};
|
|
75
102
|
const client = Util.getClientForProfile();
|
|
76
|
-
// get base url of the platform
|
|
103
|
+
// get base url of the platform cliente
|
|
77
104
|
const baseUrl = client.getPlatformBaseURL();
|
|
78
105
|
// Different build approach for public vs platform apps
|
|
79
106
|
if (type === 'public') {
|
|
80
107
|
// Public apps: standard SPA build with index.html
|
|
81
108
|
manifest.icon = icon;
|
|
82
109
|
console.log("Building public app...");
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
try {
|
|
111
|
+
await build({
|
|
112
|
+
root: path,
|
|
113
|
+
build: {
|
|
114
|
+
outDir: distPath,
|
|
115
|
+
emptyOutDir: true,
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
console.error('\n❌ Public app build failed:');
|
|
121
|
+
console.error(formatBuildError(error));
|
|
122
|
+
throw new Error('Public app build failed.');
|
|
123
|
+
}
|
|
90
124
|
console.log("✅ Public app build completed successfully");
|
|
91
125
|
// Build SSR renderer if specified for public apps
|
|
92
126
|
if (ssrRenderer) {
|
|
@@ -119,7 +153,8 @@ class ForgeCommand {
|
|
|
119
153
|
console.log('✓ SSR renderer built successfully');
|
|
120
154
|
}
|
|
121
155
|
catch (error) {
|
|
122
|
-
console.error('
|
|
156
|
+
console.error('\n❌ Failed to build SSR renderer:');
|
|
157
|
+
console.error(formatBuildError(error));
|
|
123
158
|
throw new Error('SSR renderer build failed.');
|
|
124
159
|
}
|
|
125
160
|
}
|
|
@@ -241,10 +276,7 @@ class ForgeCommand {
|
|
|
241
276
|
const reason = result.reason;
|
|
242
277
|
console.error(`\n View: ${viewName}`);
|
|
243
278
|
console.error(` Input: ${views[viewName]}`);
|
|
244
|
-
console.error(
|
|
245
|
-
if (reason?.stack) {
|
|
246
|
-
console.error(`\n${reason.stack}`);
|
|
247
|
-
}
|
|
279
|
+
console.error(formatBuildError(reason));
|
|
248
280
|
});
|
|
249
281
|
throw new Error('View build failed. See errors above.');
|
|
250
282
|
}
|
|
@@ -260,10 +292,7 @@ class ForgeCommand {
|
|
|
260
292
|
const reason = result.reason;
|
|
261
293
|
console.error(`\n Script: ${scriptName}`);
|
|
262
294
|
console.error(` Input: ${scripts[scriptName]}`);
|
|
263
|
-
console.error(
|
|
264
|
-
if (reason?.stack) {
|
|
265
|
-
console.error(`\n${reason.stack}`);
|
|
266
|
-
}
|
|
295
|
+
console.error(formatBuildError(reason));
|
|
267
296
|
});
|
|
268
297
|
throw new Error('Script build failed. See errors above.');
|
|
269
298
|
}
|
|
@@ -308,19 +337,115 @@ class ForgeCommand {
|
|
|
308
337
|
async runDev(options) {
|
|
309
338
|
const { path } = options;
|
|
310
339
|
const host = Util.getCurrentProfile().host;
|
|
340
|
+
// Load ptkl.config.js to discover views and app name
|
|
341
|
+
let appName;
|
|
342
|
+
let views;
|
|
343
|
+
try {
|
|
344
|
+
console.log(`${path}/ptkl.config.js`);
|
|
345
|
+
const configModule = await import(`${path}/ptkl.config.js`);
|
|
346
|
+
appName = configModule.default?.name;
|
|
347
|
+
views = configModule.default?.views;
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
// no manifest found – continue without view watch publishing
|
|
351
|
+
console.error(`Manifest not found: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
// Ensure the local dev hub is running (start it on first forge dev, reuse on subsequent ones)
|
|
354
|
+
const { ensureHubRunning, pushBundle, DEV_HUB_PORT } = await import('../lib/devHub.js');
|
|
355
|
+
const hubStatus = await ensureHubRunning();
|
|
356
|
+
console.log(hubStatus === 'started'
|
|
357
|
+
? `🔌 Dev hub started on http://localhost:${DEV_HUB_PORT}`
|
|
358
|
+
: `🔌 Reusing existing dev hub on http://localhost:${DEV_HUB_PORT}`);
|
|
359
|
+
// Start Vite dev server for the app, injecting window.__PTKL_DEV_HUB__ so AppView
|
|
360
|
+
// inside this app automatically knows where to fetch local view bundles from.
|
|
311
361
|
const server = await createServer({
|
|
312
|
-
root: path,
|
|
362
|
+
root: path,
|
|
313
363
|
define: {
|
|
314
364
|
__ENV_VARIABLES__: JSON.stringify({
|
|
315
365
|
API_HOST: host,
|
|
316
366
|
INTEGRATION_API: `${host}/luma/integrations`,
|
|
317
367
|
PROJECT_API_TOKEN: Util.getCurrentProfile().token,
|
|
318
|
-
})
|
|
319
|
-
|
|
368
|
+
}),
|
|
369
|
+
DEV_HUB_PORT
|
|
370
|
+
},
|
|
371
|
+
plugins: [],
|
|
320
372
|
});
|
|
321
373
|
await server.listen();
|
|
322
374
|
console.log('Current profile:', Util.getCurrentProfile().name);
|
|
323
375
|
console.log('Dev server running at:', server?.resolvedUrls?.local[0]);
|
|
376
|
+
// Build every view declared in ptkl.config.js in watch mode.
|
|
377
|
+
// On each successful rebuild the bundle is pushed to the dev hub so any
|
|
378
|
+
// other locally-running app that embeds this view via AppView picks it up instantly.
|
|
379
|
+
if (appName && views && Object.keys(views).length > 0) {
|
|
380
|
+
const os = await import('os');
|
|
381
|
+
const { join } = await import('path');
|
|
382
|
+
const { readFileSync, existsSync, mkdirSync } = await import('fs');
|
|
383
|
+
// Build output goes to a per-app temp dir so concurrent forge dev runs don't clash
|
|
384
|
+
const tmpDir = join(os.default.tmpdir(), `ptkl-dev-${appName}`);
|
|
385
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
386
|
+
console.log(`👁️ Watching views for "${appName}": [${Object.keys(views).join(', ')}]`);
|
|
387
|
+
// createServer() sets process.env.NODE_ENV='development'.
|
|
388
|
+
// Vite 7 uses process.env.NODE_ENV (not config.mode) to determine isProduction,
|
|
389
|
+
// which controls jsxDev in esbuild. Force production here so the watch build
|
|
390
|
+
// compiles JSX without dev helpers (_store, _source, etc.).
|
|
391
|
+
process.env.NODE_ENV = 'production';
|
|
392
|
+
for (const viewName of Object.keys(views)) {
|
|
393
|
+
// Use a per-view output dir so each view's assets don't collide
|
|
394
|
+
// (two views could otherwise both emit a file named from their input, e.g. `index.css`)
|
|
395
|
+
const viewOutDir = join(tmpDir, viewName);
|
|
396
|
+
mkdirSync(viewOutDir, { recursive: true });
|
|
397
|
+
const watcher = (await build({
|
|
398
|
+
root: path,
|
|
399
|
+
mode: 'production',
|
|
400
|
+
logLevel: 'silent',
|
|
401
|
+
plugins: [],
|
|
402
|
+
resolve: {
|
|
403
|
+
// Force all react/* imports to resolve from the app's own node_modules,
|
|
404
|
+
// preventing a second React instance from symlinked deps (e.g. @ptkl/components)
|
|
405
|
+
// that have their own node_modules/react.
|
|
406
|
+
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
|
407
|
+
},
|
|
408
|
+
build: {
|
|
409
|
+
outDir: viewOutDir,
|
|
410
|
+
emptyOutDir: false,
|
|
411
|
+
watch: {},
|
|
412
|
+
rollupOptions: {
|
|
413
|
+
input: views[viewName],
|
|
414
|
+
output: {
|
|
415
|
+
format: 'esm',
|
|
416
|
+
// Use the view KEY as the output name, not the input filename.
|
|
417
|
+
// This ensures `stockPage` (entry: src/stock.tsx) outputs `stockPage.bundle.js`
|
|
418
|
+
// rather than `stock.bundle.js`.
|
|
419
|
+
entryFileNames: `${viewName}.bundle.js`,
|
|
420
|
+
assetFileNames: `${viewName}.[ext]`,
|
|
421
|
+
manualChunks: undefined,
|
|
422
|
+
inlineDynamicImports: true,
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
}));
|
|
427
|
+
watcher.on('event', async (event) => {
|
|
428
|
+
if (event.code === 'BUNDLE_END') {
|
|
429
|
+
const bundleFile = join(viewOutDir, `${viewName}.bundle.js`);
|
|
430
|
+
const cssFile = join(viewOutDir, `${viewName}.css`);
|
|
431
|
+
if (existsSync(bundleFile)) {
|
|
432
|
+
const bundle = readFileSync(bundleFile, 'utf-8');
|
|
433
|
+
const css = existsSync(cssFile) ? readFileSync(cssFile, 'utf-8') : null;
|
|
434
|
+
try {
|
|
435
|
+
await pushBundle(appName, viewName, bundle, css);
|
|
436
|
+
console.log(`🔄 [${appName}/${viewName}] published to dev hub`);
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
console.warn(`⚠️ [${appName}/${viewName}] failed to push to dev hub:`, err?.message ?? err);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (event.code === 'ERROR') {
|
|
444
|
+
console.error(`❌ [${appName}/${viewName}] build error:`, event.error?.message ?? event.error);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
324
449
|
}
|
|
325
450
|
async removeVersion(options) {
|
|
326
451
|
const { ref, version } = options;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ptkl Forge Dev Hub
|
|
3
|
+
*
|
|
4
|
+
* A tiny HTTP + WebSocket server (port 4111) that acts as a relay between
|
|
5
|
+
* running `ptkl forge dev` processes (publishers) and browser apps that use
|
|
6
|
+
* AppView to embed views from other locally-running apps (subscribers).
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. `ptkl forge dev` starts → ensures hub is running (starts it if not)
|
|
10
|
+
* 2. Vite watch builds each view → pushes bundle to hub via POST /push/:scope/:view
|
|
11
|
+
* 3. Hub stores bundle in memory, broadcasts { type: 'update', scope, view } to all WS clients
|
|
12
|
+
* 4. AppView (in any browser tab running a ptkl forge dev app) receives the WS message,
|
|
13
|
+
* invalidates its local cache, re-fetches bundle from GET /bundle/:scope/:view.bundle.js
|
|
14
|
+
* and hot-swaps the mounted view
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'http';
|
|
17
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
18
|
+
export const DEV_HUB_PORT = 4111;
|
|
19
|
+
// In-memory store: scope → view → bundle
|
|
20
|
+
const store = new Map();
|
|
21
|
+
let _wss = null;
|
|
22
|
+
function broadcast(payload) {
|
|
23
|
+
if (!_wss)
|
|
24
|
+
return;
|
|
25
|
+
const msg = JSON.stringify(payload);
|
|
26
|
+
_wss.clients.forEach((client) => {
|
|
27
|
+
if (client.readyState === WebSocket.OPEN)
|
|
28
|
+
client.send(msg);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function readBody(req) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let body = '';
|
|
34
|
+
req.on('data', (chunk) => (body += chunk));
|
|
35
|
+
req.on('end', () => resolve(body));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function startHub() {
|
|
39
|
+
const server = createServer(async (req, res) => {
|
|
40
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
41
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
43
|
+
if (req.method === 'OPTIONS') {
|
|
44
|
+
res.writeHead(204);
|
|
45
|
+
res.end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(req.url, `http://localhost:${DEV_HUB_PORT}`);
|
|
49
|
+
// GET /bundle/:scope/:view.bundle.js OR /bundle/:scope/:view.css
|
|
50
|
+
const bundleMatch = url.pathname.match(/^\/bundle\/([^/]+)\/([^/]+)\.(bundle\.js|css)$/);
|
|
51
|
+
if (bundleMatch && req.method === 'GET') {
|
|
52
|
+
const [, scope, view, ext] = bundleMatch;
|
|
53
|
+
const entry = store.get(scope)?.get(view);
|
|
54
|
+
if (!entry) {
|
|
55
|
+
res.writeHead(404);
|
|
56
|
+
res.end(`Dev hub: no bundle for ${scope}/${view}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (ext === 'bundle.js') {
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
61
|
+
res.end(entry.bundle);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (!entry.css) {
|
|
65
|
+
res.writeHead(404);
|
|
66
|
+
res.end(`Dev hub: no CSS for ${scope}/${view}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'text/css' });
|
|
70
|
+
res.end(entry.css);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// GET /manifest – list all registered scopes and their views
|
|
75
|
+
if (url.pathname === '/manifest' && req.method === 'GET') {
|
|
76
|
+
const manifest = {};
|
|
77
|
+
store.forEach((views, scope) => {
|
|
78
|
+
manifest[scope] = Array.from(views.keys());
|
|
79
|
+
});
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
81
|
+
res.end(JSON.stringify(manifest));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// POST /push/:scope/:view – called by forge dev to publish a freshly-built bundle
|
|
85
|
+
const pushMatch = url.pathname.match(/^\/push\/([^/]+)\/([^/]+)$/);
|
|
86
|
+
if (pushMatch && req.method === 'POST') {
|
|
87
|
+
const [, scope, view] = pushMatch;
|
|
88
|
+
const body = await readBody(req);
|
|
89
|
+
const { bundle, css } = JSON.parse(body);
|
|
90
|
+
if (!store.has(scope))
|
|
91
|
+
store.set(scope, new Map());
|
|
92
|
+
store.get(scope).set(view, { bundle, css: css ?? null });
|
|
93
|
+
broadcast({ type: 'update', scope, view });
|
|
94
|
+
res.writeHead(200);
|
|
95
|
+
res.end('ok');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(404);
|
|
99
|
+
res.end();
|
|
100
|
+
});
|
|
101
|
+
_wss = new WebSocketServer({ server });
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
server.listen(DEV_HUB_PORT, () => resolve());
|
|
104
|
+
server.on('error', reject);
|
|
105
|
+
});
|
|
106
|
+
console.log(`🔌 Ptkl dev hub listening on http://localhost:${DEV_HUB_PORT}`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Start the hub if it is not already running.
|
|
110
|
+
* Returns 'started' if this process started the hub, 'existing' if it was already up.
|
|
111
|
+
*/
|
|
112
|
+
export async function ensureHubRunning() {
|
|
113
|
+
try {
|
|
114
|
+
const { default: axios } = await import('axios');
|
|
115
|
+
await axios.get(`http://localhost:${DEV_HUB_PORT}/manifest`, { timeout: 1000 });
|
|
116
|
+
return 'existing';
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
await startHub();
|
|
120
|
+
return 'started';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Push a freshly-built view bundle to the hub.
|
|
125
|
+
* Called from within the Vite watch build event handler.
|
|
126
|
+
*/
|
|
127
|
+
export async function pushBundle(scope, view, bundle, css) {
|
|
128
|
+
const { default: axios } = await import('axios');
|
|
129
|
+
await axios.post(`http://localhost:${DEV_HUB_PORT}/push/${scope}/${view}`, { bundle, css }, { headers: { 'Content-Type': 'application/json' } });
|
|
130
|
+
}
|