@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.
@@ -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, // Set your project root
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptkl/toolkit",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "description": "A command-line toolkit for managing Protokol platform applications, profiles, functions, and components",
5
5
  "keywords": [
6
6
  "protokol",