@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.
@@ -28,7 +28,9 @@ process.on('uncaughtException', (err) => {
28
28
  const { response } = err;
29
29
  if (response && response.data) {
30
30
  console.error(response.data.message);
31
- return;
31
+ }
32
+ else {
33
+ console.error(err.message || String(err));
32
34
  }
33
35
  process.exit(1);
34
36
  });
@@ -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 client
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
- await build({
84
- root: path,
85
- build: {
86
- outDir: distPath,
87
- emptyOutDir: true,
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(' Failed to build SSR renderer:', error.message || 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(` Error: ${reason?.message || String(reason)}`);
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(` Error: ${reason?.message || String(reason)}`);
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, // Set your project root
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptkl/toolkit",
3
- "version": "0.8.12",
3
+ "version": "0.9.1",
4
4
  "description": "A command-line toolkit for managing Protokol platform applications, profiles, functions, and components",
5
5
  "keywords": [
6
6
  "protokol",