@notis_ai/cli 0.2.0 → 0.2.2
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/README.md +122 -158
- package/dist/scaffolds/notes/app/globals.css +3 -0
- package/dist/scaffolds/notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notes/app/page.tsx +1465 -0
- package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notes/components.json +20 -0
- package/dist/scaffolds/notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notes/notis.config.ts +31 -0
- package/dist/scaffolds/notes/package.json +31 -0
- package/dist/scaffolds/notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notes/tsconfig.json +22 -0
- package/dist/scaffolds/notes/vite.config.ts +10 -0
- package/dist/scaffolds.json +13 -0
- package/package.json +12 -2
- package/skills/notis-apps/SKILL.md +162 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +258 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +1029 -59
- package/src/command-specs/helpers.js +6 -41
- package/src/command-specs/index.js +1 -7
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +29 -34
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +605 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +1037 -82
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +36 -5
- package/src/runtime/transport.js +131 -6
- package/template/.harness/index.html.tmpl +211 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +90 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/metadata/cover.png +0 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +37 -0
- package/template/package.json +31 -0
- package/template/packages/sdk/package.json +32 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +71 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
- package/template/packages/sdk/src/index.ts +54 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +161 -0
- package/template/packages/sdk/src/styles.css +38 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
- package/src/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -0,0 +1,605 @@
|
|
|
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 { spawn } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync, statSync, watch as fsWatch } from 'node:fs';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
collectArtifactFiles,
|
|
22
|
+
collectSourceFiles,
|
|
23
|
+
exportNameFromPath,
|
|
24
|
+
getBundleDir,
|
|
25
|
+
loadAppConfig,
|
|
26
|
+
prepareArtifactBuild,
|
|
27
|
+
readManifest,
|
|
28
|
+
} from './app-platform.js';
|
|
29
|
+
|
|
30
|
+
const CONTENT_TYPES = {
|
|
31
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
32
|
+
'.css': 'text/css; charset=utf-8',
|
|
33
|
+
'.map': 'application/json; charset=utf-8',
|
|
34
|
+
};
|
|
35
|
+
const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
|
|
37
|
+
const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
|
|
38
|
+
const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
|
|
39
|
+
const FALLBACK_REACT_VERSION = '19.0.0';
|
|
40
|
+
|
|
41
|
+
function extFor(pathname) {
|
|
42
|
+
const idx = pathname.lastIndexOf('.');
|
|
43
|
+
return idx === -1 ? '' : pathname.slice(idx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isAllowedOrigin(origin) {
|
|
47
|
+
if (!origin) return true;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = new URL(origin);
|
|
50
|
+
if (parsed.protocol === 'notis-app:') return true;
|
|
51
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
|
52
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function corsHeaders(origin) {
|
|
59
|
+
const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
|
|
60
|
+
return {
|
|
61
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
62
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
63
|
+
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
|
|
64
|
+
'Cache-Control': 'no-store',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeJoin(baseDir, relPath) {
|
|
69
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
70
|
+
if (normalized.includes('..')) return null;
|
|
71
|
+
return join(baseDir, normalized);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scriptJson(value) {
|
|
75
|
+
return JSON.stringify(value)
|
|
76
|
+
.replace(/</g, '\\u003c')
|
|
77
|
+
.replace(/\u2028/g, '\\u2028')
|
|
78
|
+
.replace(/\u2029/g, '\\u2029');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function timingMs(startedAt) {
|
|
82
|
+
return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatTimingMs(value) {
|
|
86
|
+
return value.toFixed(value < 10 ? 2 : 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJsonFile(path) {
|
|
90
|
+
if (!existsSync(path)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reactVersionFromPeer(peerRange) {
|
|
101
|
+
if (typeof peerRange !== 'string' || !peerRange) {
|
|
102
|
+
return FALLBACK_REACT_VERSION;
|
|
103
|
+
}
|
|
104
|
+
const exact = peerRange.match(/\d+\.\d+\.\d+/);
|
|
105
|
+
if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
|
|
106
|
+
return exact[0];
|
|
107
|
+
}
|
|
108
|
+
if (peerRange.includes('19') || peerRange.includes('18')) {
|
|
109
|
+
return FALLBACK_REACT_VERSION;
|
|
110
|
+
}
|
|
111
|
+
return FALLBACK_REACT_VERSION;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveHarnessReactVersion(projectDir) {
|
|
115
|
+
const candidates = [
|
|
116
|
+
join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
|
|
117
|
+
join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
|
|
118
|
+
join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
|
|
119
|
+
];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
const pkg = readJsonFile(candidate);
|
|
122
|
+
const peer = pkg?.peerDependencies?.react;
|
|
123
|
+
if (peer) {
|
|
124
|
+
return reactVersionFromPeer(peer);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return FALLBACK_REACT_VERSION;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function titleFromSlug(slug) {
|
|
131
|
+
return String(slug || '')
|
|
132
|
+
.replace(/[-_]+/g, ' ')
|
|
133
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeDatabaseDescriptors(databases) {
|
|
137
|
+
return (Array.isArray(databases) ? databases : [])
|
|
138
|
+
.map((entry) => {
|
|
139
|
+
if (typeof entry === 'string') {
|
|
140
|
+
return {
|
|
141
|
+
slug: entry,
|
|
142
|
+
title: titleFromSlug(entry),
|
|
143
|
+
description: null,
|
|
144
|
+
icon: null,
|
|
145
|
+
properties: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
149
|
+
return {
|
|
150
|
+
slug: entry.slug,
|
|
151
|
+
title: entry.title || titleFromSlug(entry.slug),
|
|
152
|
+
description: entry.description || null,
|
|
153
|
+
icon: entry.icon || null,
|
|
154
|
+
properties: Array.isArray(entry.properties) ? entry.properties : [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
})
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeToolDescriptors(tools) {
|
|
163
|
+
return (Array.isArray(tools) ? tools : [])
|
|
164
|
+
.map((entry) => {
|
|
165
|
+
if (typeof entry === 'string') {
|
|
166
|
+
return { name: entry };
|
|
167
|
+
}
|
|
168
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
169
|
+
return entry;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
})
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function defaultRouteForManifest(manifest) {
|
|
177
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
178
|
+
return routes.find((route) => route?.default) || routes[0] || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findHarnessRoute(manifest, routeSlug) {
|
|
182
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
183
|
+
if (!routeSlug) {
|
|
184
|
+
return defaultRouteForManifest(manifest);
|
|
185
|
+
}
|
|
186
|
+
return routes.find((route) => route?.slug === routeSlug) || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
|
|
190
|
+
const databases = normalizeDatabaseDescriptors(
|
|
191
|
+
Array.isArray(appConfig?.databases) && appConfig.databases.length
|
|
192
|
+
? appConfig.databases
|
|
193
|
+
: manifest.databases,
|
|
194
|
+
);
|
|
195
|
+
const tools = normalizeToolDescriptors(
|
|
196
|
+
Array.isArray(appConfig?.tools) && appConfig.tools.length
|
|
197
|
+
? appConfig.tools
|
|
198
|
+
: manifest.tools,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
app: {
|
|
203
|
+
id: state.appId || 'harness-app',
|
|
204
|
+
slug: state.slug,
|
|
205
|
+
name: manifest.app?.name || appConfig?.name || state.slug,
|
|
206
|
+
icon: manifest.app?.icon || appConfig?.icon || null,
|
|
207
|
+
description: manifest.app?.description || appConfig?.description || null,
|
|
208
|
+
},
|
|
209
|
+
route: {
|
|
210
|
+
slug: route.slug,
|
|
211
|
+
path: route.path || '/',
|
|
212
|
+
name: route.name || titleFromSlug(route.slug),
|
|
213
|
+
icon: route.icon || null,
|
|
214
|
+
parentSlug: route.parentSlug || null,
|
|
215
|
+
default: Boolean(route.default),
|
|
216
|
+
collection: route.collection || null,
|
|
217
|
+
},
|
|
218
|
+
databases,
|
|
219
|
+
context: { collectionItem: null },
|
|
220
|
+
tools,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }) {
|
|
225
|
+
const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
|
|
226
|
+
const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route });
|
|
227
|
+
const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
|
|
228
|
+
const replacements = {
|
|
229
|
+
'{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
|
|
230
|
+
'{{ROUTE_EXPORT}}': scriptJson(routeExport),
|
|
231
|
+
'{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
|
|
232
|
+
'{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
|
|
233
|
+
'{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
|
|
234
|
+
'{{JWT}}': scriptJson(harnessOptions.jwt || null),
|
|
235
|
+
};
|
|
236
|
+
let html = template;
|
|
237
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
238
|
+
html = html.replaceAll(token, value);
|
|
239
|
+
}
|
|
240
|
+
return html;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Start the dev server for one or more apps.
|
|
245
|
+
*
|
|
246
|
+
* @param {{apps: Array<{slug: string, projectDir: string, appId?: string, userId?: string}>, port: number, watch?: boolean, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
|
|
247
|
+
*/
|
|
248
|
+
export async function startAppDevServer({
|
|
249
|
+
apps,
|
|
250
|
+
port,
|
|
251
|
+
watch = true,
|
|
252
|
+
harness = {},
|
|
253
|
+
log = (msg) => process.stdout.write(`${msg}\n`),
|
|
254
|
+
logError = (msg) => process.stderr.write(`${msg}\n`),
|
|
255
|
+
}) {
|
|
256
|
+
if (!Array.isArray(apps) || apps.length === 0) {
|
|
257
|
+
throw new Error('startAppDevServer requires at least one app.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const appState = new Map();
|
|
261
|
+
for (const app of apps) {
|
|
262
|
+
appState.set(app.slug, {
|
|
263
|
+
slug: app.slug,
|
|
264
|
+
projectDir: app.projectDir,
|
|
265
|
+
appId: app.appId || null,
|
|
266
|
+
userId: app.userId || null,
|
|
267
|
+
canonicalBundleDir: getBundleDir(app.projectDir),
|
|
268
|
+
bundleDir: null,
|
|
269
|
+
jsPath: null,
|
|
270
|
+
sseClients: new Set(),
|
|
271
|
+
watcher: null,
|
|
272
|
+
buildProcess: null,
|
|
273
|
+
lastMtimeMs: 0,
|
|
274
|
+
watchPollTimer: null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function broadcastReload(slug) {
|
|
279
|
+
const state = appState.get(slug);
|
|
280
|
+
if (!state) return;
|
|
281
|
+
const payload = `event: reload\ndata: ${Date.now()}\n\n`;
|
|
282
|
+
for (const res of state.sseClients) {
|
|
283
|
+
try {
|
|
284
|
+
res.write(payload);
|
|
285
|
+
} catch {
|
|
286
|
+
state.sseClients.delete(res);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function matchAppRoute(pathname) {
|
|
292
|
+
// Matches `/a/<slug>/<rest>` — returns { slug, rest } or null.
|
|
293
|
+
const match = pathname.match(/^\/a\/([^/]+)(?:\/(.*))?$/);
|
|
294
|
+
if (!match) return null;
|
|
295
|
+
return { slug: decodeURIComponent(match[1]), rest: match[2] || '' };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolveBundleDir(state) {
|
|
299
|
+
if (existsSync(join(state.canonicalBundleDir, 'app.js'))) {
|
|
300
|
+
return state.canonicalBundleDir;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function updateBundleDir(state, bundleDir) {
|
|
306
|
+
if (!bundleDir || state.bundleDir === bundleDir) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (state.watcher) {
|
|
310
|
+
try {
|
|
311
|
+
state.watcher.close();
|
|
312
|
+
} catch {
|
|
313
|
+
// ignore
|
|
314
|
+
}
|
|
315
|
+
state.watcher = null;
|
|
316
|
+
}
|
|
317
|
+
state.bundleDir = bundleDir;
|
|
318
|
+
state.jsPath = join(bundleDir, 'app.js');
|
|
319
|
+
try {
|
|
320
|
+
state.lastMtimeMs = statSync(state.jsPath).mtimeMs;
|
|
321
|
+
} catch {
|
|
322
|
+
state.lastMtimeMs = 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function serveHarness(req, res, url, state, headers) {
|
|
327
|
+
let manifest;
|
|
328
|
+
let appConfig;
|
|
329
|
+
try {
|
|
330
|
+
manifest = readManifest(state.projectDir);
|
|
331
|
+
appConfig = await loadAppConfig(state.projectDir);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
res.writeHead(500, {
|
|
334
|
+
...headers,
|
|
335
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
336
|
+
});
|
|
337
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const routeSlug = url.searchParams.get('route') || '';
|
|
342
|
+
const route = findHarnessRoute(manifest, routeSlug);
|
|
343
|
+
if (!route) {
|
|
344
|
+
res.writeHead(404, {
|
|
345
|
+
...headers,
|
|
346
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
347
|
+
});
|
|
348
|
+
res.end(routeSlug ? `unknown route: ${routeSlug}` : 'no default route');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const html = renderHarnessHtml({
|
|
353
|
+
state,
|
|
354
|
+
manifest,
|
|
355
|
+
appConfig,
|
|
356
|
+
route,
|
|
357
|
+
harnessOptions: harness,
|
|
358
|
+
});
|
|
359
|
+
res.writeHead(200, {
|
|
360
|
+
...headers,
|
|
361
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
362
|
+
'Content-Length': String(Buffer.byteLength(html)),
|
|
363
|
+
});
|
|
364
|
+
if (req.method === 'HEAD') {
|
|
365
|
+
res.end();
|
|
366
|
+
} else {
|
|
367
|
+
res.end(html);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const server = createServer((req, res) => {
|
|
372
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
|
373
|
+
const origin = req.headers.origin || '';
|
|
374
|
+
const headers = corsHeaders(origin);
|
|
375
|
+
if (origin && !isAllowedOrigin(origin)) {
|
|
376
|
+
res.writeHead(403, headers);
|
|
377
|
+
res.end('origin not allowed');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (req.method === 'OPTIONS') {
|
|
382
|
+
res.writeHead(204, headers);
|
|
383
|
+
res.end();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
388
|
+
res.writeHead(405, headers);
|
|
389
|
+
res.end('method not allowed');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (url.pathname === '/healthz') {
|
|
394
|
+
const now = new Date().toISOString();
|
|
395
|
+
const sessions = Array.from(appState.values()).map((state) => ({
|
|
396
|
+
appId: state.appId,
|
|
397
|
+
userId: state.userId,
|
|
398
|
+
devSlug: state.slug,
|
|
399
|
+
bundleBaseUrl: `http://127.0.0.1:${port}/a/${state.slug}`,
|
|
400
|
+
projectDir: state.projectDir,
|
|
401
|
+
lastHeartbeatAt: now,
|
|
402
|
+
status: 'connected',
|
|
403
|
+
}));
|
|
404
|
+
res.writeHead(200, {
|
|
405
|
+
...headers,
|
|
406
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
407
|
+
});
|
|
408
|
+
res.end(JSON.stringify({ ok: true, apps: Array.from(appState.keys()), sessions }));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const routed = matchAppRoute(url.pathname);
|
|
413
|
+
if (!routed) {
|
|
414
|
+
res.writeHead(404, headers);
|
|
415
|
+
res.end('not found');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const state = appState.get(routed.slug);
|
|
420
|
+
if (!state) {
|
|
421
|
+
res.writeHead(404, headers);
|
|
422
|
+
res.end(`unknown app: ${routed.slug}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (routed.rest === 'events') {
|
|
427
|
+
res.writeHead(200, {
|
|
428
|
+
...headers,
|
|
429
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
430
|
+
Connection: 'keep-alive',
|
|
431
|
+
});
|
|
432
|
+
res.write(': connected\n\n');
|
|
433
|
+
state.sseClients.add(res);
|
|
434
|
+
req.on('close', () => state.sseClients.delete(res));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (routed.rest === 'harness') {
|
|
439
|
+
void serveHarness(req, res, url, state, headers);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (routed.rest === 'snapshot') {
|
|
444
|
+
try {
|
|
445
|
+
updateBundleDir(state, resolveBundleDir(state));
|
|
446
|
+
const manifest = readManifest(state.projectDir);
|
|
447
|
+
const payload = {
|
|
448
|
+
app_id: state.appId,
|
|
449
|
+
dev_slug: state.slug,
|
|
450
|
+
manifest,
|
|
451
|
+
files: collectArtifactFiles(state.projectDir),
|
|
452
|
+
source_files: collectSourceFiles(state.projectDir),
|
|
453
|
+
};
|
|
454
|
+
const body = JSON.stringify(payload);
|
|
455
|
+
res.writeHead(200, {
|
|
456
|
+
...headers,
|
|
457
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
458
|
+
'Content-Length': String(Buffer.byteLength(body)),
|
|
459
|
+
});
|
|
460
|
+
if (req.method === 'HEAD') {
|
|
461
|
+
res.end();
|
|
462
|
+
} else {
|
|
463
|
+
res.end(body);
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
res.writeHead(500, {
|
|
468
|
+
...headers,
|
|
469
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
470
|
+
});
|
|
471
|
+
res.end(JSON.stringify({ error: message }));
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (routed.rest.startsWith('bundle/')) {
|
|
477
|
+
const startedAt = process.hrtime.bigint();
|
|
478
|
+
updateBundleDir(state, resolveBundleDir(state));
|
|
479
|
+
const rel = routed.rest.slice('bundle/'.length);
|
|
480
|
+
if (!state.bundleDir) {
|
|
481
|
+
res.writeHead(404, headers);
|
|
482
|
+
res.end('not found');
|
|
483
|
+
log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const full = safeJoin(state.bundleDir, rel);
|
|
487
|
+
if (!full || !existsSync(full)) {
|
|
488
|
+
res.writeHead(404, headers);
|
|
489
|
+
res.end('not found');
|
|
490
|
+
log(`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 404 in ${formatTimingMs(timingMs(startedAt))}ms`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const ext = extFor(rel);
|
|
494
|
+
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
|
|
495
|
+
const content = readFileSync(full);
|
|
496
|
+
res.writeHead(200, {
|
|
497
|
+
...headers,
|
|
498
|
+
'Content-Type': contentType,
|
|
499
|
+
'Content-Length': String(content.byteLength),
|
|
500
|
+
});
|
|
501
|
+
if (req.method === 'HEAD') {
|
|
502
|
+
res.end();
|
|
503
|
+
} else {
|
|
504
|
+
res.end(content);
|
|
505
|
+
}
|
|
506
|
+
log(
|
|
507
|
+
`[notis apps timing] ${state.slug}: GET bundle/${rel} -> 200 ` +
|
|
508
|
+
`${content.byteLength}B in ${formatTimingMs(timingMs(startedAt))}ms`,
|
|
509
|
+
);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
res.writeHead(404, headers);
|
|
514
|
+
res.end('not found');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
518
|
+
server.once('error', rejectPromise);
|
|
519
|
+
server.listen(port, '127.0.0.1', () => {
|
|
520
|
+
server.off('error', rejectPromise);
|
|
521
|
+
resolvePromise();
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
function startBundleWatch(state) {
|
|
526
|
+
if (state.watcher || !state.bundleDir) return;
|
|
527
|
+
try {
|
|
528
|
+
state.watcher = fsWatch(state.bundleDir, { persistent: false }, (_event, filename) => {
|
|
529
|
+
if (!filename || filename !== 'app.js') return;
|
|
530
|
+
try {
|
|
531
|
+
const stat = statSync(state.jsPath);
|
|
532
|
+
if (stat.mtimeMs === state.lastMtimeMs) return;
|
|
533
|
+
state.lastMtimeMs = stat.mtimeMs;
|
|
534
|
+
log(`[notis apps dev] ${state.slug}: bundle updated — reloading portal`);
|
|
535
|
+
broadcastReload(state.slug);
|
|
536
|
+
} catch {
|
|
537
|
+
// Bundle file may be missing mid-write; ignore.
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
logError(`[notis apps dev] ${state.slug}: watch failed: ${error.message}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function pollForBundleAndWatch(state) {
|
|
546
|
+
const bundleDir = resolveBundleDir(state);
|
|
547
|
+
if (bundleDir) {
|
|
548
|
+
updateBundleDir(state, bundleDir);
|
|
549
|
+
startBundleWatch(state);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
state.watchPollTimer = setTimeout(() => pollForBundleAndWatch(state), 300);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
for (const state of appState.values()) {
|
|
556
|
+
if (watch) {
|
|
557
|
+
await prepareArtifactBuild(state.projectDir);
|
|
558
|
+
pollForBundleAndWatch(state);
|
|
559
|
+
|
|
560
|
+
state.buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
|
|
561
|
+
cwd: state.projectDir,
|
|
562
|
+
stdio: 'inherit',
|
|
563
|
+
env: { ...process.env, NOTIS_DEV: '1' },
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
state.buildProcess.on('exit', (code) => {
|
|
567
|
+
if (code !== 0 && code !== null) {
|
|
568
|
+
logError(`[notis apps dev] ${state.slug}: vite build --watch exited with code ${code}`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
} else {
|
|
572
|
+
updateBundleDir(state, resolveBundleDir(state));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
log(`[notis apps dev] ${state.slug}: serving bundle at http://127.0.0.1:${port}/a/${state.slug}/bundle/app.js`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
port,
|
|
580
|
+
async close() {
|
|
581
|
+
for (const state of appState.values()) {
|
|
582
|
+
if (state.watchPollTimer) clearTimeout(state.watchPollTimer);
|
|
583
|
+
if (state.watcher) {
|
|
584
|
+
try {
|
|
585
|
+
state.watcher.close();
|
|
586
|
+
} catch {
|
|
587
|
+
// ignore
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
for (const res of state.sseClients) {
|
|
591
|
+
try {
|
|
592
|
+
res.end();
|
|
593
|
+
} catch {
|
|
594
|
+
// ignore
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
state.sseClients.clear();
|
|
598
|
+
if (state.buildProcess && state.buildProcess.exitCode === null) {
|
|
599
|
+
state.buildProcess.kill('SIGTERM');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
await new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { CONFIG_DIR } from './profiles.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_APP_DEV_SESSIONS_FILE = join(CONFIG_DIR, 'app-dev-sessions.json');
|
|
7
|
+
export const APP_DEV_SESSIONS_FILE = DEFAULT_APP_DEV_SESSIONS_FILE;
|
|
8
|
+
export const APP_DEV_SESSIONS_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export function getAppDevSessionsFile(filePath) {
|
|
11
|
+
if (filePath) {
|
|
12
|
+
return filePath;
|
|
13
|
+
}
|
|
14
|
+
const envPath = process.env.NOTIS_APP_DEV_SESSIONS_FILE;
|
|
15
|
+
return typeof envPath === 'string' && envPath.trim()
|
|
16
|
+
? envPath.trim()
|
|
17
|
+
: DEFAULT_APP_DEV_SESSIONS_FILE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeSessions(raw) {
|
|
21
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.sessions)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return raw.sessions.filter((session) =>
|
|
25
|
+
session &&
|
|
26
|
+
typeof session === 'object' &&
|
|
27
|
+
typeof session.sessionId === 'string' &&
|
|
28
|
+
typeof session.appId === 'string' &&
|
|
29
|
+
typeof session.bundleBaseUrl === 'string',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readAppDevSessions(filePath) {
|
|
34
|
+
const resolvedFilePath = getAppDevSessionsFile(filePath);
|
|
35
|
+
if (!existsSync(resolvedFilePath)) {
|
|
36
|
+
return { version: APP_DEV_SESSIONS_VERSION, sessions: [] };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(readFileSync(resolvedFilePath, 'utf8'));
|
|
40
|
+
return {
|
|
41
|
+
version: APP_DEV_SESSIONS_VERSION,
|
|
42
|
+
sessions: normalizeSessions(raw),
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
return { version: APP_DEV_SESSIONS_VERSION, sessions: [] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeAppDevSessions(registry, filePath) {
|
|
50
|
+
const resolvedFilePath = getAppDevSessionsFile(filePath);
|
|
51
|
+
mkdirSync(dirname(resolvedFilePath), { recursive: true, mode: 0o700 });
|
|
52
|
+
const normalized = {
|
|
53
|
+
version: APP_DEV_SESSIONS_VERSION,
|
|
54
|
+
sessions: normalizeSessions(registry),
|
|
55
|
+
};
|
|
56
|
+
const tempPath = `${resolvedFilePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
57
|
+
writeFileSync(tempPath, JSON.stringify(normalized, null, 2), { mode: 0o600 });
|
|
58
|
+
renameSync(tempPath, resolvedFilePath);
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function upsertAppDevSessions(sessions, filePath) {
|
|
63
|
+
const nextSessions = Array.isArray(sessions) ? sessions : [sessions];
|
|
64
|
+
const registry = readAppDevSessions(filePath);
|
|
65
|
+
const keys = new Set(nextSessions.map((session) => `${session.sessionId}:${session.appId}`));
|
|
66
|
+
registry.sessions = [
|
|
67
|
+
...registry.sessions.filter((session) => !keys.has(`${session.sessionId}:${session.appId}`)),
|
|
68
|
+
...nextSessions,
|
|
69
|
+
];
|
|
70
|
+
return writeAppDevSessions(registry, filePath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function heartbeatAppDevSession(sessionId, lastHeartbeatAt, filePath) {
|
|
74
|
+
const registry = readAppDevSessions(filePath);
|
|
75
|
+
registry.sessions = registry.sessions.map((session) =>
|
|
76
|
+
session.sessionId === sessionId
|
|
77
|
+
? { ...session, lastHeartbeatAt }
|
|
78
|
+
: session,
|
|
79
|
+
);
|
|
80
|
+
return writeAppDevSessions(registry, filePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function removeAppDevSession(sessionId, filePath) {
|
|
84
|
+
const registry = readAppDevSessions(filePath);
|
|
85
|
+
registry.sessions = registry.sessions.filter((session) => session.sessionId !== sessionId);
|
|
86
|
+
return writeAppDevSessions(registry, filePath);
|
|
87
|
+
}
|