@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.157.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.
Files changed (35) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5168 -7271
  4. package/dist/base-skills/notis-apps/SKILL.md +108 -194
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +322 -1560
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +45 -44
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +20 -7
  23. package/template/packages/sdk/src/config.ts +0 -2
  24. package/template/packages/sdk/src/interactions.ts +2 -1
  25. package/template/packages/sdk/src/styles.css +28 -1
  26. package/src/runtime/app-dev-build-supervisor.js +0 -47
  27. package/src/runtime/app-dev-build.js +0 -41
  28. package/src/runtime/app-dev-consumers.js +0 -154
  29. package/src/runtime/app-dev-host-lock.js +0 -80
  30. package/src/runtime/app-dev-process-identity.js +0 -111
  31. package/src/runtime/app-dev-roots.js +0 -284
  32. package/src/runtime/app-dev-server.js +0 -1136
  33. package/src/runtime/app-dev-sessions.js +0 -185
  34. package/src/runtime/cli-mode.generated.js +0 -5
  35. package/src/runtime/cli-mode.js +0 -34
@@ -1,1136 +0,0 @@
1
- /**
2
- * Dev server for `notis apps dev`.
3
- *
4
- * Hosts one or more app bundles on a single HTTP server bound to 127.0.0.1.
5
- * Each app lives at `/a/<slug>/...`:
6
- * - `/a/<slug>/bundle/app.js` — watched Vite output
7
- * - `/a/<slug>/bundle/app.css` — watched Vite output
8
- * - `/a/<slug>/events` — SSE push on rebuild
9
- *
10
- * A `vite build --watch` is spawned per app so the monorepo case (one CLI
11
- * invocation at the repo root, many apps under `apps/*`) stays cheap to run.
12
- */
13
-
14
- import { createServer } from 'node:http';
15
- import { execFileSync, spawn } from 'node:child_process';
16
- import {
17
- appendFileSync,
18
- existsSync,
19
- mkdirSync,
20
- readFileSync,
21
- renameSync,
22
- rmSync,
23
- statSync,
24
- watch as fsWatch,
25
- } from 'node:fs';
26
- import { freemem, loadavg, totalmem } from 'node:os';
27
- import { dirname, join, resolve } from 'node:path';
28
- import { setTimeout as delay } from 'node:timers/promises';
29
- import { fileURLToPath } from 'node:url';
30
-
31
- import {
32
- collectArtifactFiles,
33
- collectSourceFiles,
34
- exportNameFromPath,
35
- getBundleDir,
36
- loadAppConfig,
37
- prepareArtifactBuild,
38
- readManifest,
39
- readLinkedState,
40
- writeLinkedState,
41
- } from './app-platform.js';
42
- import {
43
- linkAppDevSessionTarget,
44
- readAppDevSessions,
45
- } from './app-dev-sessions.js';
46
- import { captureDesktopWatcherOwnership } from './app-dev-process-identity.js';
47
- import { startAppDevBuild } from './app-dev-build.js';
48
-
49
- const CONTENT_TYPES = {
50
- '.js': 'application/javascript; charset=utf-8',
51
- '.css': 'text/css; charset=utf-8',
52
- '.map': 'application/json; charset=utf-8',
53
- };
54
- const MAX_JSON_BODY_BYTES = 64 * 1024;
55
- const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
56
- const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
57
- const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
58
- const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
59
- const FALLBACK_REACT_VERSION = '19.0.0';
60
- const BUILD_PROCESS_STOP_GRACE_MS = 1_000;
61
- const DEV_DIAGNOSTIC_INTERVAL_MS = 30_000;
62
- const DEV_DIAGNOSTIC_MAX_BYTES = 20 * 1024 * 1024;
63
-
64
- function processGroupIsRunning(pid, signalProcess = process.kill) {
65
- try {
66
- signalProcess(-pid, 0);
67
- return true;
68
- } catch (error) {
69
- return error?.code !== 'ESRCH';
70
- }
71
- }
72
-
73
- function readProcessGroupRssBytes(groupPids) {
74
- if (process.platform === 'win32' || groupPids.size === 0) return new Map();
75
- try {
76
- const output = execFileSync('ps', ['-axo', 'pgid=,rss='], {
77
- encoding: 'utf8',
78
- maxBuffer: 1024 * 1024,
79
- stdio: ['ignore', 'pipe', 'ignore'],
80
- });
81
- const rssByGroup = new Map();
82
- for (const line of output.split('\n')) {
83
- const [rawGroupPid, rawRssKiB] = line.trim().split(/\s+/, 2);
84
- const groupPid = Number.parseInt(rawGroupPid, 10);
85
- if (!groupPids.has(groupPid)) continue;
86
- const rssKiB = Number.parseInt(rawRssKiB, 10);
87
- if (!Number.isFinite(rssKiB)) continue;
88
- rssByGroup.set(groupPid, (rssByGroup.get(groupPid) || 0) + (rssKiB * 1024));
89
- }
90
- return rssByGroup;
91
- } catch {
92
- return new Map();
93
- }
94
- }
95
-
96
- /**
97
- * Stop the npm wrapper and every Vite/esbuild descendant it launched.
98
- *
99
- * Killing only the npm PID leaves its watch process alive after a Desktop host
100
- * restart. Each watcher therefore owns a separate POSIX process group. Windows
101
- * uses taskkill's tree mode for the equivalent cleanup.
102
- */
103
- export async function terminateBuildProcessTree(child, {
104
- platform = process.platform,
105
- signalProcess = process.kill,
106
- spawnProcess = spawn,
107
- graceMs = BUILD_PROCESS_STOP_GRACE_MS,
108
- } = {}) {
109
- const pid = child?.pid;
110
- if (!Number.isSafeInteger(pid) || pid <= 0) return;
111
-
112
- if (platform === 'win32') {
113
- await new Promise((resolvePromise) => {
114
- let settled = false;
115
- const finish = () => {
116
- if (settled) return;
117
- settled = true;
118
- resolvePromise();
119
- };
120
- try {
121
- const killer = spawnProcess('taskkill.exe', ['/pid', String(pid), '/t', '/f'], {
122
- stdio: 'ignore',
123
- windowsHide: true,
124
- });
125
- killer.once('error', () => {
126
- try {
127
- child.kill('SIGTERM');
128
- } catch {
129
- // The wrapper already exited.
130
- }
131
- finish();
132
- });
133
- killer.once('exit', finish);
134
- setTimeout(finish, graceMs).unref?.();
135
- } catch {
136
- try {
137
- child.kill('SIGTERM');
138
- } catch {
139
- // The wrapper already exited.
140
- }
141
- finish();
142
- }
143
- });
144
- return;
145
- }
146
-
147
- try {
148
- signalProcess(-pid, 'SIGTERM');
149
- } catch (error) {
150
- if (error?.code === 'ESRCH') return;
151
- try {
152
- child.kill('SIGTERM');
153
- } catch {
154
- return;
155
- }
156
- }
157
-
158
- const deadline = Date.now() + graceMs;
159
- while (processGroupIsRunning(pid, signalProcess) && Date.now() < deadline) {
160
- await delay(25);
161
- }
162
- if (!processGroupIsRunning(pid, signalProcess)) return;
163
- try {
164
- signalProcess(-pid, 'SIGKILL');
165
- } catch (error) {
166
- if (error?.code !== 'ESRCH') throw error;
167
- }
168
- }
169
-
170
- function extFor(pathname) {
171
- const idx = pathname.lastIndexOf('.');
172
- return idx === -1 ? '' : pathname.slice(idx);
173
- }
174
-
175
- function isAllowedOrigin(origin) {
176
- if (!origin) return true;
177
- try {
178
- const parsed = new URL(origin);
179
- if (parsed.protocol === 'notis-app:') return true;
180
- if (!['http:', 'https:'].includes(parsed.protocol)) return false;
181
- return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
182
- } catch {
183
- return false;
184
- }
185
- }
186
-
187
- function corsHeaders(origin) {
188
- const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
189
- return {
190
- 'Access-Control-Allow-Origin': allowOrigin,
191
- 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
192
- 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
193
- 'Cache-Control': 'no-store',
194
- };
195
- }
196
-
197
- function safeJoin(baseDir, relPath) {
198
- const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
199
- if (normalized.includes('..')) return null;
200
- return join(baseDir, normalized);
201
- }
202
-
203
- function scriptJson(value) {
204
- return JSON.stringify(value)
205
- .replace(/</g, '\\u003c')
206
- .replace(/\u2028/g, '\\u2028')
207
- .replace(/\u2029/g, '\\u2029');
208
- }
209
-
210
- function timingMs(startedAt) {
211
- return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
212
- }
213
-
214
- function formatTimingMs(value) {
215
- return value.toFixed(value < 10 ? 2 : 1);
216
- }
217
-
218
- function readJsonFile(path) {
219
- if (!existsSync(path)) {
220
- return null;
221
- }
222
- try {
223
- return JSON.parse(readFileSync(path, 'utf-8'));
224
- } catch {
225
- return null;
226
- }
227
- }
228
-
229
- function readRequestJson(req) {
230
- return new Promise((resolvePromise, rejectPromise) => {
231
- let body = '';
232
- req.setEncoding('utf8');
233
- req.on('data', (chunk) => {
234
- body += chunk;
235
- if (Buffer.byteLength(body, 'utf8') > MAX_JSON_BODY_BYTES) {
236
- rejectPromise(new Error('request body too large'));
237
- req.destroy();
238
- }
239
- });
240
- req.on('end', () => {
241
- if (!body.trim()) {
242
- resolvePromise({});
243
- return;
244
- }
245
- try {
246
- resolvePromise(JSON.parse(body));
247
- } catch {
248
- rejectPromise(new Error('invalid JSON body'));
249
- }
250
- });
251
- req.on('error', rejectPromise);
252
- });
253
- }
254
-
255
- function reactVersionFromPeer(peerRange) {
256
- if (typeof peerRange !== 'string' || !peerRange) {
257
- return FALLBACK_REACT_VERSION;
258
- }
259
- const exact = peerRange.match(/\d+\.\d+\.\d+/);
260
- if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
261
- return exact[0];
262
- }
263
- if (peerRange.includes('19') || peerRange.includes('18')) {
264
- return FALLBACK_REACT_VERSION;
265
- }
266
- return FALLBACK_REACT_VERSION;
267
- }
268
-
269
- function resolveHarnessReactVersion(projectDir) {
270
- const candidates = [
271
- join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
272
- join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
273
- join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
274
- ];
275
- for (const candidate of candidates) {
276
- const pkg = readJsonFile(candidate);
277
- const peer = pkg?.peerDependencies?.react;
278
- if (peer) {
279
- return reactVersionFromPeer(peer);
280
- }
281
- }
282
- return FALLBACK_REACT_VERSION;
283
- }
284
-
285
- function titleFromSlug(slug) {
286
- return String(slug || '')
287
- .replace(/[-_]+/g, ' ')
288
- .replace(/\b\w/g, (char) => char.toUpperCase());
289
- }
290
-
291
- function normalizeDatabaseDescriptors(databases) {
292
- return (Array.isArray(databases) ? databases : [])
293
- .map((entry) => {
294
- if (typeof entry === 'string') {
295
- return {
296
- slug: entry,
297
- title: titleFromSlug(entry),
298
- description: null,
299
- icon: null,
300
- properties: [],
301
- };
302
- }
303
- if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
304
- return {
305
- slug: entry.slug,
306
- title: entry.title || titleFromSlug(entry.slug),
307
- description: entry.description || null,
308
- icon: entry.icon || null,
309
- properties: Array.isArray(entry.properties) ? entry.properties : [],
310
- };
311
- }
312
- return null;
313
- })
314
- .filter(Boolean);
315
- }
316
-
317
- function normalizeToolDescriptors(tools) {
318
- return (Array.isArray(tools) ? tools : [])
319
- .map((entry) => {
320
- if (typeof entry === 'string') {
321
- return { name: entry };
322
- }
323
- if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
324
- return entry;
325
- }
326
- return null;
327
- })
328
- .filter(Boolean);
329
- }
330
-
331
- function defaultRouteForManifest(manifest) {
332
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
333
- return routes.find((route) => route?.default) || routes[0] || null;
334
- }
335
-
336
- function findHarnessRoute(manifest, routeSlug) {
337
- const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
338
- if (!routeSlug) {
339
- return defaultRouteForManifest(manifest);
340
- }
341
- return routes.find((route) => route?.slug === routeSlug) || null;
342
- }
343
-
344
- function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
345
- const databases = normalizeDatabaseDescriptors(
346
- Array.isArray(appConfig?.databases) && appConfig.databases.length
347
- ? appConfig.databases
348
- : manifest.databases,
349
- );
350
- const tools = normalizeToolDescriptors(
351
- Array.isArray(appConfig?.tools) && appConfig.tools.length
352
- ? appConfig.tools
353
- : manifest.tools,
354
- );
355
-
356
- return {
357
- app: {
358
- id: state.appId || 'harness-app',
359
- slug: state.slug,
360
- name: manifest.app?.name || appConfig?.name || state.slug,
361
- icon: manifest.app?.icon || appConfig?.icon || null,
362
- description: manifest.app?.description || appConfig?.description || null,
363
- },
364
- route: {
365
- slug: route.slug,
366
- path: route.path || '/',
367
- name: route.name || titleFromSlug(route.slug),
368
- icon: route.icon || null,
369
- parentSlug: route.parentSlug || null,
370
- default: Boolean(route.default),
371
- resourceDeepLinks: route.resourceDeepLinks === true,
372
- collection: route.collection || null,
373
- },
374
- databases,
375
- context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
376
- tools,
377
- };
378
- }
379
-
380
- function plainObject(value) {
381
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
382
- }
383
-
384
- /**
385
- * Resolve the fixture payload injected into one harness page load.
386
- *
387
- * A scenario may override individual `tools` / `requests` keys on top of the
388
- * file-level defaults, which is how one route renders both its populated and
389
- * its empty state. Each capture is its own page load, so a shallow per-key
390
- * merge is all the isolation a scenario needs.
391
- */
392
- function harnessFixtures(projectDir, scenario) {
393
- const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
394
- const scenarios = plainObject(fixtureConfig.scenarios);
395
- const selected = scenario ? plainObject(scenarios[scenario]) : null;
396
- return {
397
- tools: { ...plainObject(fixtureConfig.tools), ...plainObject(selected?.tools) },
398
- requests: { ...plainObject(fixtureConfig.requests), ...plainObject(selected?.requests) },
399
- scenario: selected && Object.keys(selected).length > 0 ? selected : null,
400
- };
401
- }
402
-
403
- function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
404
- const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
405
- const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
406
- const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
407
- const replacements = {
408
- '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
409
- '{{ROUTE_EXPORT}}': scriptJson(routeExport),
410
- '{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
411
- '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
412
- '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
413
- '{{JWT}}': scriptJson(harnessOptions.jwt || null),
414
- '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
415
- };
416
- let html = template;
417
- for (const [token, value] of Object.entries(replacements)) {
418
- html = html.replaceAll(token, value);
419
- }
420
- return html;
421
- }
422
-
423
- /**
424
- * Start the dev server for one or more apps.
425
- *
426
- * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string, profileKey?: string, sessionId?: string, mountNonce?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, diagnosticsFile?: string | null, desktopOwnerId?: string | null, desktopOwnerScope?: string | null, terminateBuildProcess?: typeof terminateBuildProcessTree, log?: (m: string) => void, logError?: (m: string) => void}} options
427
- */
428
- export async function startAppDevServer({
429
- apps,
430
- port,
431
- watch = true,
432
- sessionsFilePath,
433
- harness = {},
434
- diagnosticsFile = process.env.NOTIS_DEV_DIAGNOSTICS_FILE || null,
435
- desktopOwnerId = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID || null,
436
- desktopOwnerScope = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE || null,
437
- terminateBuildProcess = terminateBuildProcessTree,
438
- log = (msg) => process.stdout.write(`${msg}\n`),
439
- logError = (msg) => process.stderr.write(`${msg}\n`),
440
- }) {
441
- if (!Array.isArray(apps) || apps.length === 0) {
442
- throw new Error('startAppDevServer requires at least one app.');
443
- }
444
-
445
- const appState = new Map();
446
- let diagnosticsTimer = null;
447
- let diagnosticWriteFailed = false;
448
- let serverClosing = false;
449
- // Sidebar reconciliation needs one stream for the shared host, not one
450
- // long-lived HTTP/1.1 connection per discovered app. Keeping a stream per
451
- // app exhausts Chromium's per-origin connection pool and can indefinitely
452
- // queue the active view's bundle fetch during hot reload.
453
- const hostSseClients = new Set();
454
- const createAppState = (app) => {
455
- let resolveBundleReady;
456
- const bundleReadyPromise = new Promise((resolvePromise) => {
457
- resolveBundleReady = resolvePromise;
458
- });
459
- return {
460
- slug: app.slug,
461
- projectDir: app.projectDir,
462
- appId: app.appId || null,
463
- targetAppId: app.targetAppId || null,
464
- userId: app.userId || null,
465
- profileKey: app.profileKey || null,
466
- sessionId: app.sessionId || null,
467
- mountNonce: app.mountNonce || null,
468
- canonicalBundleDir: getBundleDir(app.projectDir),
469
- bundleDir: null,
470
- jsPath: null,
471
- sseClients: new Set(),
472
- watcher: null,
473
- sourceWatchers: [],
474
- prepareTimer: null,
475
- reloadTimer: null,
476
- buildProcess: null,
477
- watcherOwnership: null,
478
- lastMtimeMs: 0,
479
- watchPollTimer: null,
480
- bundleReady: false,
481
- bundleReadyPromise,
482
- resolveBundleReady,
483
- buildProcessStopPromise: null,
484
- };
485
- };
486
- for (const app of apps) {
487
- appState.set(app.slug, createAppState(app));
488
- }
489
-
490
- function writeDevDiagnostic(event) {
491
- if (!diagnosticsFile) return;
492
- const memory = process.memoryUsage();
493
- const watcherPids = new Set(
494
- [...appState.values()]
495
- .map((state) => state.buildProcess?.pid)
496
- .filter((pid) => Number.isSafeInteger(pid) && pid > 0),
497
- );
498
- const watcherGroupRss = readProcessGroupRssBytes(watcherPids);
499
- const record = {
500
- at: new Date().toISOString(),
501
- event,
502
- host_pid: process.pid,
503
- parent_pid: process.ppid,
504
- rss_bytes: memory.rss,
505
- heap_used_bytes: memory.heapUsed,
506
- heap_total_bytes: memory.heapTotal,
507
- external_bytes: memory.external,
508
- system_free_bytes: freemem(),
509
- system_total_bytes: totalmem(),
510
- load_average_1m: loadavg()[0],
511
- watcher_groups_rss_bytes: [...watcherGroupRss.values()].reduce((total, rss) => total + rss, 0),
512
- apps: [...appState.values()].map((state) => ({
513
- slug: state.slug,
514
- project_dir: state.projectDir,
515
- watcher_pid: state.buildProcess?.pid || null,
516
- watcher_exit_code: state.buildProcess?.exitCode ?? null,
517
- watcher_signal: state.buildProcess?.signalCode ?? null,
518
- watcher_group_rss_bytes: watcherGroupRss.get(state.buildProcess?.pid) ?? null,
519
- bundle_ready: state.bundleReady,
520
- })),
521
- };
522
- try {
523
- mkdirSync(dirname(diagnosticsFile), { recursive: true, mode: 0o700 });
524
- if (existsSync(diagnosticsFile) && statSync(diagnosticsFile).size >= DEV_DIAGNOSTIC_MAX_BYTES) {
525
- const previous = `${diagnosticsFile}.previous`;
526
- rmSync(previous, { force: true });
527
- renameSync(diagnosticsFile, previous);
528
- }
529
- appendFileSync(diagnosticsFile, `${JSON.stringify(record)}\n`, { mode: 0o600 });
530
- diagnosticWriteFailed = false;
531
- } catch (error) {
532
- if (!diagnosticWriteFailed) {
533
- diagnosticWriteFailed = true;
534
- logError(`[notis apps dev] persistent diagnostics failed: ${error instanceof Error ? error.message : String(error)}`);
535
- }
536
- }
537
- }
538
-
539
- function broadcastReload(slug) {
540
- const state = appState.get(slug);
541
- if (!state) return;
542
- const payload = `event: reload\ndata: ${Date.now()}\n\n`;
543
- for (const res of state.sseClients) {
544
- try {
545
- res.write(payload);
546
- } catch {
547
- state.sseClients.delete(res);
548
- }
549
- }
550
- const hostPayload = `event: reload\ndata: ${JSON.stringify({ slug, at: Date.now() })}\n\n`;
551
- for (const res of hostSseClients) {
552
- try {
553
- res.write(hostPayload);
554
- } catch {
555
- hostSseClients.delete(res);
556
- }
557
- }
558
- }
559
-
560
- function matchAppRoute(pathname) {
561
- // Matches `/a/<slug>/<rest>` — returns { slug, rest } or null.
562
- const match = pathname.match(/^\/a\/([^/]+)(?:\/(.*))?$/);
563
- if (!match) return null;
564
- return { slug: decodeURIComponent(match[1]), rest: match[2] || '' };
565
- }
566
-
567
- function resolveBundleDir(state) {
568
- if (existsSync(join(state.canonicalBundleDir, 'app.js'))) {
569
- return state.canonicalBundleDir;
570
- }
571
- return null;
572
- }
573
-
574
- function updateBundleDir(state, bundleDir) {
575
- if (!bundleDir || state.bundleDir === bundleDir) {
576
- return;
577
- }
578
- if (state.watcher) {
579
- try {
580
- state.watcher.close();
581
- } catch {
582
- // ignore
583
- }
584
- state.watcher = null;
585
- }
586
- state.bundleDir = bundleDir;
587
- state.jsPath = join(bundleDir, 'app.js');
588
- try {
589
- state.lastMtimeMs = statSync(state.jsPath).mtimeMs;
590
- if (!state.bundleReady) {
591
- state.bundleReady = true;
592
- state.resolveBundleReady();
593
- }
594
- } catch {
595
- state.lastMtimeMs = 0;
596
- }
597
- }
598
-
599
- async function serveHarness(req, res, url, state, headers) {
600
- let manifest;
601
- let appConfig;
602
- try {
603
- manifest = readManifest(state.projectDir);
604
- appConfig = await loadAppConfig(state.projectDir);
605
- } catch (error) {
606
- res.writeHead(500, {
607
- ...headers,
608
- 'Content-Type': 'text/plain; charset=utf-8',
609
- });
610
- res.end(error instanceof Error ? error.message : String(error));
611
- return;
612
- }
613
-
614
- const routeSlug = url.searchParams.get('route') || '';
615
- const scenario = url.searchParams.get('scenario') || null;
616
- const route = findHarnessRoute(manifest, routeSlug);
617
- if (!route) {
618
- res.writeHead(404, {
619
- ...headers,
620
- 'Content-Type': 'text/plain; charset=utf-8',
621
- });
622
- res.end(routeSlug ? `unknown route: ${routeSlug}` : 'no default route');
623
- return;
624
- }
625
-
626
- const html = renderHarnessHtml({
627
- state,
628
- manifest,
629
- appConfig,
630
- route,
631
- harnessOptions: harness,
632
- scenario,
633
- });
634
- res.writeHead(200, {
635
- ...headers,
636
- 'Content-Type': 'text/html; charset=utf-8',
637
- 'Content-Length': String(Buffer.byteLength(html)),
638
- });
639
- if (req.method === 'HEAD') {
640
- res.end();
641
- } else {
642
- res.end(html);
643
- }
644
- }
645
-
646
- const server = createServer((req, res) => {
647
- const url = new URL(req.url || '/', `http://127.0.0.1:${port}`);
648
- const origin = req.headers.origin || '';
649
- const headers = corsHeaders(origin);
650
- if (origin && !isAllowedOrigin(origin)) {
651
- res.writeHead(403, headers);
652
- res.end('origin not allowed');
653
- return;
654
- }
655
-
656
- if (req.method === 'OPTIONS') {
657
- res.writeHead(204, headers);
658
- res.end();
659
- return;
660
- }
661
-
662
- if (req.method !== 'GET' && req.method !== 'HEAD') {
663
- if (req.method === 'POST') {
664
- const routed = matchAppRoute(url.pathname);
665
- const state = routed ? appState.get(routed.slug) : null;
666
- if (state && routed.rest === 'link') {
667
- void (async () => {
668
- try {
669
- const body = await readRequestJson(req);
670
- const appId = typeof body.app_id === 'string' ? body.app_id.trim() : '';
671
- const version = Number.isInteger(body.version) && body.version > 0
672
- ? body.version
673
- : null;
674
- if (!appId) {
675
- res.writeHead(400, {
676
- ...headers,
677
- 'Content-Type': 'application/json; charset=utf-8',
678
- });
679
- res.end(JSON.stringify({ error: 'app_id is required' }));
680
- return;
681
- }
682
- if (version === null) {
683
- res.writeHead(400, {
684
- ...headers,
685
- 'Content-Type': 'application/json; charset=utf-8',
686
- });
687
- res.end(JSON.stringify({ error: 'version must be a positive integer' }));
688
- return;
689
- }
690
- const proofSession = readAppDevSessions(sessionsFilePath).sessions.find((session) => (
691
- typeof body.session_id === 'string'
692
- && typeof body.mount_nonce === 'string'
693
- && session.sessionId === body.session_id
694
- && session.mountNonce === body.mount_nonce
695
- && session.devSlug === state.slug
696
- && session.projectDir === state.projectDir
697
- ));
698
- if (!proofSession) {
699
- res.writeHead(403, {
700
- ...headers,
701
- 'Content-Type': 'application/json; charset=utf-8',
702
- });
703
- res.end(JSON.stringify({ error: 'invalid app development session proof' }));
704
- return;
705
- }
706
- const expectedAppId = proofSession.targetAppId || proofSession.appId;
707
- if (!expectedAppId || appId !== expectedAppId) {
708
- res.writeHead(409, {
709
- ...headers,
710
- 'Content-Type': 'application/json; charset=utf-8',
711
- });
712
- res.end(JSON.stringify({ error: 'app_id does not match this development session' }));
713
- return;
714
- }
715
-
716
- const currentState = readLinkedState(
717
- state.projectDir,
718
- proofSession.profileKey || state.profileKey,
719
- ) || {};
720
- const now = new Date().toISOString();
721
- const promotedInPlace = proofSession.appId === appId;
722
- const nextState = {
723
- ...currentState,
724
- app_id: appId,
725
- linked_at: currentState.linked_at || now,
726
- version,
727
- deployed_at: now,
728
- updated_at: now,
729
- };
730
- if (promotedInPlace) {
731
- delete nextState.dev_app_id;
732
- delete nextState.dev_linked_at;
733
- } else {
734
- nextState.dev_app_id = proofSession.appId || currentState.dev_app_id;
735
- nextState.dev_linked_at = currentState.dev_linked_at || now;
736
- }
737
- writeLinkedState(
738
- state.projectDir,
739
- nextState,
740
- proofSession.profileKey || state.profileKey,
741
- );
742
- linkAppDevSessionTarget({
743
- sessionId: proofSession.sessionId,
744
- appId: proofSession.appId,
745
- devSlug: state.slug,
746
- targetAppId: appId,
747
- lastHeartbeatAt: now,
748
- }, sessionsFilePath);
749
- const response = {
750
- ok: true,
751
- app_id: appId,
752
- dev_app_id: promotedInPlace ? null : proofSession.appId || null,
753
- target_app_id: appId,
754
- version,
755
- };
756
- res.writeHead(200, {
757
- ...headers,
758
- 'Content-Type': 'application/json; charset=utf-8',
759
- });
760
- res.end(JSON.stringify(response));
761
- } catch (error) {
762
- const message = error instanceof Error ? error.message : String(error);
763
- res.writeHead(400, {
764
- ...headers,
765
- 'Content-Type': 'application/json; charset=utf-8',
766
- });
767
- res.end(JSON.stringify({ error: message }));
768
- }
769
- })();
770
- return;
771
- }
772
- }
773
- res.writeHead(405, headers);
774
- res.end('method not allowed');
775
- return;
776
- }
777
-
778
- if (url.pathname === '/healthz') {
779
- const now = new Date().toISOString();
780
- const sessions = Array.from(appState.values()).map((state) => ({
781
- appId: state.appId,
782
- targetAppId: state.targetAppId || undefined,
783
- userId: state.userId,
784
- devSlug: state.slug,
785
- bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
786
- projectDir: state.projectDir,
787
- lastHeartbeatAt: now,
788
- status: 'connected',
789
- }));
790
- res.writeHead(200, {
791
- ...headers,
792
- 'Content-Type': 'application/json; charset=utf-8',
793
- });
794
- res.end(JSON.stringify({ ok: true, apps: Array.from(appState.keys()), sessions }));
795
- return;
796
- }
797
-
798
- if (url.pathname === '/events') {
799
- res.writeHead(200, {
800
- ...headers,
801
- 'Content-Type': 'text/event-stream; charset=utf-8',
802
- Connection: 'keep-alive',
803
- });
804
- res.write(': connected\n\n');
805
- hostSseClients.add(res);
806
- req.on('close', () => hostSseClients.delete(res));
807
- return;
808
- }
809
-
810
- const routed = matchAppRoute(url.pathname);
811
- if (!routed) {
812
- res.writeHead(404, headers);
813
- res.end('not found');
814
- return;
815
- }
816
-
817
- const state = appState.get(routed.slug);
818
- if (!state) {
819
- res.writeHead(404, headers);
820
- res.end(`unknown app: ${routed.slug}`);
821
- return;
822
- }
823
-
824
- if (routed.rest === 'events') {
825
- res.writeHead(200, {
826
- ...headers,
827
- 'Content-Type': 'text/event-stream; charset=utf-8',
828
- Connection: 'keep-alive',
829
- });
830
- res.write(': connected\n\n');
831
- state.sseClients.add(res);
832
- req.on('close', () => state.sseClients.delete(res));
833
- return;
834
- }
835
-
836
- if (routed.rest === 'harness') {
837
- void serveHarness(req, res, url, state, headers);
838
- return;
839
- }
840
-
841
- if (routed.rest === 'snapshot') {
842
- try {
843
- updateBundleDir(state, resolveBundleDir(state));
844
- const manifest = readManifest(state.projectDir);
845
- const payload = {
846
- app_id: state.appId,
847
- target_app_id: state.targetAppId || null,
848
- dev_slug: state.slug,
849
- manifest,
850
- files: collectArtifactFiles(state.projectDir),
851
- source_files: collectSourceFiles(state.projectDir),
852
- };
853
- const body = JSON.stringify(payload);
854
- res.writeHead(200, {
855
- ...headers,
856
- 'Content-Type': 'application/json; charset=utf-8',
857
- 'Content-Length': String(Buffer.byteLength(body)),
858
- });
859
- if (req.method === 'HEAD') {
860
- res.end();
861
- } else {
862
- res.end(body);
863
- }
864
- } catch (error) {
865
- const message = error instanceof Error ? error.message : String(error);
866
- res.writeHead(500, {
867
- ...headers,
868
- 'Content-Type': 'application/json; charset=utf-8',
869
- });
870
- res.end(JSON.stringify({ error: message }));
871
- }
872
- return;
873
- }
874
-
875
- if (routed.rest.startsWith('bundle/')) {
876
- const startedAt = process.hrtime.bigint();
877
- updateBundleDir(state, resolveBundleDir(state));
878
- const rel = routed.rest.slice('bundle/'.length);
879
- if (!state.bundleDir) {
880
- res.writeHead(404, headers);
881
- res.end('not found');
882
- log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
883
- return;
884
- }
885
- const full = safeJoin(state.bundleDir, rel);
886
- if (!full || !existsSync(full)) {
887
- res.writeHead(404, headers);
888
- res.end('not found');
889
- log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
890
- return;
891
- }
892
- const ext = extFor(rel);
893
- const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
894
- const content = readFileSync(full);
895
- res.writeHead(200, {
896
- ...headers,
897
- 'Content-Type': contentType,
898
- 'Content-Length': String(content.byteLength),
899
- });
900
- if (req.method === 'HEAD') {
901
- res.end();
902
- } else {
903
- res.end(content);
904
- }
905
- log(
906
- `[notis apps timing] ${state.slug}: GET bundle/${rel} -> 200 ` +
907
- `${content.byteLength}B in ${formatTimingMs(timingMs(startedAt))}ms`,
908
- );
909
- return;
910
- }
911
-
912
- res.writeHead(404, headers);
913
- res.end('not found');
914
- });
915
-
916
- await new Promise((resolvePromise, rejectPromise) => {
917
- server.once('error', rejectPromise);
918
- server.listen(port, '127.0.0.1', () => {
919
- server.off('error', rejectPromise);
920
- resolvePromise();
921
- });
922
- });
923
-
924
- function startBundleWatch(state) {
925
- if (state.watcher || !state.bundleDir) return;
926
- try {
927
- state.watcher = fsWatch(state.bundleDir, { persistent: false }, (_event, filename) => {
928
- if (!filename || filename !== 'app.js') return;
929
- // Vite may emit the filesystem notification while its output file is
930
- // still being replaced. Debounce the notification so consumers never
931
- // fetch the previous or partially-written bundle under the new build
932
- // revision.
933
- if (state.reloadTimer) clearTimeout(state.reloadTimer);
934
- state.reloadTimer = setTimeout(() => {
935
- state.reloadTimer = null;
936
- try {
937
- const stat = statSync(state.jsPath);
938
- if (stat.mtimeMs === state.lastMtimeMs) return;
939
- state.lastMtimeMs = stat.mtimeMs;
940
- log(`[notis apps dev] ${state.slug}: bundle updated — reloading portal`);
941
- broadcastReload(state.slug);
942
- } catch {
943
- // Bundle file may be missing mid-write; the next change retries.
944
- }
945
- }, 100);
946
- });
947
- } catch (error) {
948
- logError(`[notis apps dev] ${state.slug}: watch failed: ${error.message}`);
949
- }
950
- }
951
-
952
- function pollForBundleAndWatch(state) {
953
- const bundleDir = resolveBundleDir(state);
954
- if (bundleDir) {
955
- updateBundleDir(state, bundleDir);
956
- startBundleWatch(state);
957
- return;
958
- }
959
- state.watchPollTimer = setTimeout(() => pollForBundleAndWatch(state), 300);
960
- }
961
-
962
- function watchManifestInputs(state) {
963
- const schedulePrepare = () => {
964
- if (state.prepareTimer) clearTimeout(state.prepareTimer);
965
- state.prepareTimer = setTimeout(() => {
966
- state.prepareTimer = null;
967
- void prepareArtifactBuild(state.projectDir).then(() => {
968
- // Name, icon, and route metadata can change without changing the
969
- // compiled JS bytes. Notify consumers immediately; a generated-entry
970
- // change will produce the normal second reload after Vite rebuilds.
971
- broadcastReload(state.slug);
972
- }).catch((error) => {
973
- const message = error instanceof Error ? error.message : String(error);
974
- logError(`[notis apps dev] ${state.slug}: ${message}`);
975
- });
976
- }, 75);
977
- };
978
- try {
979
- state.sourceWatchers.push(fsWatch(
980
- state.projectDir,
981
- { persistent: false },
982
- (_event, filename) => {
983
- if (filename && /^notis\.config\.(?:ts|js|mjs)$/.test(String(filename))) {
984
- schedulePrepare();
985
- }
986
- },
987
- ));
988
- } catch (error) {
989
- logError(`[notis apps dev] ${state.slug}: config watch failed: ${error.message}`);
990
- }
991
- const appDir = join(state.projectDir, 'app');
992
- if (existsSync(appDir)) {
993
- try {
994
- state.sourceWatchers.push(fsWatch(
995
- appDir,
996
- { persistent: false, recursive: process.platform === 'darwin' || process.platform === 'win32' },
997
- schedulePrepare,
998
- ));
999
- } catch (error) {
1000
- logError(`[notis apps dev] ${state.slug}: route watch failed: ${error.message}`);
1001
- }
1002
- }
1003
- }
1004
-
1005
- for (const state of appState.values()) {
1006
- if (watch) {
1007
- await prepareArtifactBuild(state.projectDir);
1008
- watchManifestInputs(state);
1009
- pollForBundleAndWatch(state);
1010
-
1011
- const buildProcess = await startAppDevBuild(state.projectDir);
1012
- state.buildProcess = buildProcess;
1013
- for (let attempt = 0; attempt < 5 && !state.watcherOwnership; attempt += 1) {
1014
- state.watcherOwnership = captureDesktopWatcherOwnership({
1015
- pid: buildProcess.pid,
1016
- projectDir: state.projectDir,
1017
- desktopOwnerId,
1018
- desktopOwnerScope,
1019
- });
1020
- if (!state.watcherOwnership && attempt < 4) await delay(10);
1021
- }
1022
-
1023
- buildProcess.on('exit', (code) => {
1024
- if (code !== 0 && code !== null) {
1025
- logError(`[notis apps dev] ${state.slug}: vite build --watch exited with code ${code}`);
1026
- }
1027
- if (!serverClosing) {
1028
- if (state.buildProcess === buildProcess) state.buildProcess = null;
1029
- const stopPromise = terminateBuildProcess(buildProcess).catch((error) => {
1030
- logError(`[notis apps dev] ${state.slug}: watcher cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1031
- });
1032
- state.buildProcessStopPromise = stopPromise;
1033
- void stopPromise.finally(() => {
1034
- if (state.buildProcessStopPromise === stopPromise) {
1035
- state.buildProcessStopPromise = null;
1036
- }
1037
- });
1038
- }
1039
- });
1040
- } else {
1041
- updateBundleDir(state, resolveBundleDir(state));
1042
- }
1043
-
1044
- log(`[notis apps dev] ${state.slug}: serving bundle at http://127.0.0.1:${port}/a/${state.slug}/bundle/app.js`);
1045
- }
1046
-
1047
- writeDevDiagnostic('started');
1048
- if (diagnosticsFile) {
1049
- diagnosticsTimer = setInterval(() => writeDevDiagnostic('sample'), DEV_DIAGNOSTIC_INTERVAL_MS);
1050
- diagnosticsTimer.unref?.();
1051
- }
1052
-
1053
- return {
1054
- port,
1055
- updateApp(slug, updates = {}) {
1056
- const state = appState.get(slug);
1057
- if (!state) throw new Error(`unknown app: ${slug}`);
1058
- for (const key of ['appId', 'targetAppId', 'userId', 'profileKey', 'sessionId', 'mountNonce']) {
1059
- if (Object.prototype.hasOwnProperty.call(updates, key)) {
1060
- state[key] = updates[key] || null;
1061
- }
1062
- }
1063
- },
1064
- isBundleReady(slug) {
1065
- const state = appState.get(slug);
1066
- if (!state) throw new Error(`unknown app: ${slug}`);
1067
- return state.bundleReady;
1068
- },
1069
- waitForBundle(slug) {
1070
- const state = appState.get(slug);
1071
- if (!state) return Promise.reject(new Error(`unknown app: ${slug}`));
1072
- return state.bundleReadyPromise;
1073
- },
1074
- getWatcherOwnership(slug) {
1075
- const state = appState.get(slug);
1076
- if (!state) throw new Error(`unknown app: ${slug}`);
1077
- return state.watcherOwnership ? { ...state.watcherOwnership } : null;
1078
- },
1079
- async close() {
1080
- serverClosing = true;
1081
- if (diagnosticsTimer) {
1082
- clearInterval(diagnosticsTimer);
1083
- diagnosticsTimer = null;
1084
- }
1085
- writeDevDiagnostic('stopping');
1086
- const buildProcessStops = [];
1087
- for (const state of appState.values()) {
1088
- if (state.prepareTimer) clearTimeout(state.prepareTimer);
1089
- if (state.reloadTimer) clearTimeout(state.reloadTimer);
1090
- for (const watcher of state.sourceWatchers) {
1091
- try {
1092
- watcher.close();
1093
- } catch {
1094
- // ignore
1095
- }
1096
- }
1097
- state.sourceWatchers = [];
1098
- if (state.watchPollTimer) clearTimeout(state.watchPollTimer);
1099
- if (state.watcher) {
1100
- try {
1101
- state.watcher.close();
1102
- } catch {
1103
- // ignore
1104
- }
1105
- }
1106
- for (const res of state.sseClients) {
1107
- try {
1108
- res.end();
1109
- } catch {
1110
- // ignore
1111
- }
1112
- }
1113
- state.sseClients.clear();
1114
- if (state.buildProcessStopPromise) {
1115
- buildProcessStops.push(state.buildProcessStopPromise);
1116
- }
1117
- if (state.buildProcess) {
1118
- const buildProcess = state.buildProcess;
1119
- state.buildProcess = null;
1120
- buildProcessStops.push(terminateBuildProcess(buildProcess));
1121
- }
1122
- }
1123
- await Promise.allSettled(buildProcessStops);
1124
- for (const res of hostSseClients) {
1125
- try {
1126
- res.end();
1127
- } catch {
1128
- // ignore
1129
- }
1130
- }
1131
- hostSseClients.clear();
1132
- await new Promise((resolvePromise) => server.close(() => resolvePromise()));
1133
- writeDevDiagnostic('stopped');
1134
- },
1135
- };
1136
- }