@ptkl/toolkit 0.8.8 → 0.9.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/dist/commands/forge.js +104 -4
- package/dist/lib/devHub.js +130 -0
- package/package.json +1 -1
package/dist/commands/forge.js
CHANGED
|
@@ -2,7 +2,7 @@ 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';
|
|
@@ -50,6 +50,10 @@ class ForgeCommand {
|
|
|
50
50
|
process.chdir(path);
|
|
51
51
|
const module = await import(`${path}/ptkl.config.js`);
|
|
52
52
|
const { views, name, version, distPath, icon, type, label, permissions, install_permissions, runtime_permissions, entitlements, requires, scripts, ssrRenderer, } = module.default ?? {};
|
|
53
|
+
// Clean dist folder before building to remove stale files
|
|
54
|
+
if (existsSync(distPath)) {
|
|
55
|
+
rmSync(distPath, { recursive: true, force: true });
|
|
56
|
+
}
|
|
53
57
|
// Validate combined permissions limit
|
|
54
58
|
const rtPerms = runtime_permissions ?? [];
|
|
55
59
|
const ents = entitlements ?? [];
|
|
@@ -308,19 +312,115 @@ class ForgeCommand {
|
|
|
308
312
|
async runDev(options) {
|
|
309
313
|
const { path } = options;
|
|
310
314
|
const host = Util.getCurrentProfile().host;
|
|
315
|
+
// Load ptkl.config.js to discover views and app name
|
|
316
|
+
let appName;
|
|
317
|
+
let views;
|
|
318
|
+
try {
|
|
319
|
+
console.log(`${path}/ptkl.config.js`);
|
|
320
|
+
const configModule = await import(`${path}/ptkl.config.js`);
|
|
321
|
+
appName = configModule.default?.name;
|
|
322
|
+
views = configModule.default?.views;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
// no manifest found – continue without view watch publishing
|
|
326
|
+
console.error(`Manifest not found: ${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
// Ensure the local dev hub is running (start it on first forge dev, reuse on subsequent ones)
|
|
329
|
+
const { ensureHubRunning, pushBundle, DEV_HUB_PORT } = await import('../lib/devHub.js');
|
|
330
|
+
const hubStatus = await ensureHubRunning();
|
|
331
|
+
console.log(hubStatus === 'started'
|
|
332
|
+
? `🔌 Dev hub started on http://localhost:${DEV_HUB_PORT}`
|
|
333
|
+
: `🔌 Reusing existing dev hub on http://localhost:${DEV_HUB_PORT}`);
|
|
334
|
+
// Start Vite dev server for the app, injecting window.__PTKL_DEV_HUB__ so AppView
|
|
335
|
+
// inside this app automatically knows where to fetch local view bundles from.
|
|
311
336
|
const server = await createServer({
|
|
312
|
-
root: path,
|
|
337
|
+
root: path,
|
|
313
338
|
define: {
|
|
314
339
|
__ENV_VARIABLES__: JSON.stringify({
|
|
315
340
|
API_HOST: host,
|
|
316
341
|
INTEGRATION_API: `${host}/luma/integrations`,
|
|
317
342
|
PROJECT_API_TOKEN: Util.getCurrentProfile().token,
|
|
318
|
-
})
|
|
319
|
-
|
|
343
|
+
}),
|
|
344
|
+
DEV_HUB_PORT
|
|
345
|
+
},
|
|
346
|
+
plugins: [],
|
|
320
347
|
});
|
|
321
348
|
await server.listen();
|
|
322
349
|
console.log('Current profile:', Util.getCurrentProfile().name);
|
|
323
350
|
console.log('Dev server running at:', server?.resolvedUrls?.local[0]);
|
|
351
|
+
// Build every view declared in ptkl.config.js in watch mode.
|
|
352
|
+
// On each successful rebuild the bundle is pushed to the dev hub so any
|
|
353
|
+
// other locally-running app that embeds this view via AppView picks it up instantly.
|
|
354
|
+
if (appName && views && Object.keys(views).length > 0) {
|
|
355
|
+
const os = await import('os');
|
|
356
|
+
const { join } = await import('path');
|
|
357
|
+
const { readFileSync, existsSync, mkdirSync } = await import('fs');
|
|
358
|
+
// Build output goes to a per-app temp dir so concurrent forge dev runs don't clash
|
|
359
|
+
const tmpDir = join(os.default.tmpdir(), `ptkl-dev-${appName}`);
|
|
360
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
361
|
+
console.log(`👁️ Watching views for "${appName}": [${Object.keys(views).join(', ')}]`);
|
|
362
|
+
// createServer() sets process.env.NODE_ENV='development'.
|
|
363
|
+
// Vite 7 uses process.env.NODE_ENV (not config.mode) to determine isProduction,
|
|
364
|
+
// which controls jsxDev in esbuild. Force production here so the watch build
|
|
365
|
+
// compiles JSX without dev helpers (_store, _source, etc.).
|
|
366
|
+
process.env.NODE_ENV = 'production';
|
|
367
|
+
for (const viewName of Object.keys(views)) {
|
|
368
|
+
// Use a per-view output dir so each view's assets don't collide
|
|
369
|
+
// (two views could otherwise both emit a file named from their input, e.g. `index.css`)
|
|
370
|
+
const viewOutDir = join(tmpDir, viewName);
|
|
371
|
+
mkdirSync(viewOutDir, { recursive: true });
|
|
372
|
+
const watcher = (await build({
|
|
373
|
+
root: path,
|
|
374
|
+
mode: 'production',
|
|
375
|
+
logLevel: 'silent',
|
|
376
|
+
plugins: [],
|
|
377
|
+
resolve: {
|
|
378
|
+
// Force all react/* imports to resolve from the app's own node_modules,
|
|
379
|
+
// preventing a second React instance from symlinked deps (e.g. @ptkl/components)
|
|
380
|
+
// that have their own node_modules/react.
|
|
381
|
+
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
|
382
|
+
},
|
|
383
|
+
build: {
|
|
384
|
+
outDir: viewOutDir,
|
|
385
|
+
emptyOutDir: false,
|
|
386
|
+
watch: {},
|
|
387
|
+
rollupOptions: {
|
|
388
|
+
input: views[viewName],
|
|
389
|
+
output: {
|
|
390
|
+
format: 'esm',
|
|
391
|
+
// Use the view KEY as the output name, not the input filename.
|
|
392
|
+
// This ensures `stockPage` (entry: src/stock.tsx) outputs `stockPage.bundle.js`
|
|
393
|
+
// rather than `stock.bundle.js`.
|
|
394
|
+
entryFileNames: `${viewName}.bundle.js`,
|
|
395
|
+
assetFileNames: `${viewName}.[ext]`,
|
|
396
|
+
manualChunks: undefined,
|
|
397
|
+
inlineDynamicImports: true,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
}));
|
|
402
|
+
watcher.on('event', async (event) => {
|
|
403
|
+
if (event.code === 'BUNDLE_END') {
|
|
404
|
+
const bundleFile = join(viewOutDir, `${viewName}.bundle.js`);
|
|
405
|
+
const cssFile = join(viewOutDir, `${viewName}.css`);
|
|
406
|
+
if (existsSync(bundleFile)) {
|
|
407
|
+
const bundle = readFileSync(bundleFile, 'utf-8');
|
|
408
|
+
const css = existsSync(cssFile) ? readFileSync(cssFile, 'utf-8') : null;
|
|
409
|
+
try {
|
|
410
|
+
await pushBundle(appName, viewName, bundle, css);
|
|
411
|
+
console.log(`🔄 [${appName}/${viewName}] published to dev hub`);
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
console.warn(`⚠️ [${appName}/${viewName}] failed to push to dev hub:`, err?.message ?? err);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (event.code === 'ERROR') {
|
|
419
|
+
console.error(`❌ [${appName}/${viewName}] build error:`, event.error?.message ?? event.error);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
324
424
|
}
|
|
325
425
|
async removeVersion(options) {
|
|
326
426
|
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
|
+
}
|