design-learn-server 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-learn-server",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Design-Learn server skeleton for REST/WS/MCP routes",
6
6
  "main": "src/server.js",
@@ -0,0 +1,89 @@
1
+ const http = require('http');
2
+ const { URL } = require('url');
3
+
4
+ const { createRouter } = require('./router');
5
+ const { registerRoutes } = require('../routes');
6
+ const { sendJson, sendMethodNotAllowed } = require('./response');
7
+ const { readJsonBody } = require('./request');
8
+ const { handleWebSocketUpgrade } = require('../ws/upgrade');
9
+
10
+ function isMcpPath(pathname) {
11
+ return pathname === '/mcp' || pathname.startsWith('/mcp/');
12
+ }
13
+
14
+ function isWsPath(pathname) {
15
+ return pathname === '/ws' || pathname.startsWith('/ws/');
16
+ }
17
+
18
+ function handleWsHttpFallback(req, res) {
19
+ sendJson(res, 426, { error: 'upgrade_required' });
20
+ }
21
+
22
+ function createServer(deps) {
23
+ const router = createRouter();
24
+ registerRoutes(router, deps);
25
+
26
+ async function handleRequest(req, res) {
27
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
28
+ const pathname = url.pathname;
29
+
30
+ console.log(`[http] ${req.method} ${pathname}`);
31
+
32
+ if (isMcpPath(pathname)) {
33
+ if (req.method === 'POST') {
34
+ const body = await readJsonBody(req, res);
35
+ if (!body) {
36
+ return;
37
+ }
38
+ await deps.mcpHandler.handleRequest(req, res, body);
39
+ return;
40
+ }
41
+
42
+ await deps.mcpHandler.handleRequest(req, res);
43
+ return;
44
+ }
45
+
46
+ if (isWsPath(pathname)) {
47
+ return handleWsHttpFallback(req, res);
48
+ }
49
+
50
+ const result = await router.handle(req, res, url, deps);
51
+ if (result?.methodNotAllowed) {
52
+ return sendMethodNotAllowed(res);
53
+ }
54
+ if (result?.handled) {
55
+ return;
56
+ }
57
+
58
+ return sendJson(res, 404, { error: 'not_found', path: pathname });
59
+ }
60
+
61
+ const server = http.createServer((req, res) => {
62
+ handleRequest(req, res).catch((error) => {
63
+ console.error('[http] handler error', error);
64
+ if (!res.headersSent) {
65
+ sendJson(res, 500, { error: 'internal_error' });
66
+ }
67
+ });
68
+ });
69
+
70
+ server.on('upgrade', (req, socket) => {
71
+ try {
72
+ handleWebSocketUpgrade(req, socket);
73
+ } catch (error) {
74
+ console.error('[ws] upgrade error', error);
75
+ socket.destroy();
76
+ }
77
+ });
78
+
79
+ server.on('clientError', (err, socket) => {
80
+ console.error('[http] client error', err.message);
81
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
82
+ });
83
+
84
+ return server;
85
+ }
86
+
87
+ module.exports = {
88
+ createServer,
89
+ };
@@ -0,0 +1,68 @@
1
+ function readJsonBody(req, res) {
2
+ return new Promise((resolve) => {
3
+ let data = '';
4
+ req.on('data', (chunk) => {
5
+ data += chunk;
6
+ });
7
+ req.on('end', () => {
8
+ if (!data) {
9
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
10
+ res.end(JSON.stringify({ error: 'empty_body' }));
11
+ resolve(null);
12
+ return;
13
+ }
14
+ try {
15
+ resolve(JSON.parse(data));
16
+ } catch (error) {
17
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
18
+ res.end(JSON.stringify({ error: 'invalid_json' }));
19
+ resolve(null);
20
+ }
21
+ });
22
+ });
23
+ }
24
+
25
+ function parseOptionalBoolean(value) {
26
+ if (value === null || value === undefined || value === '') {
27
+ return undefined;
28
+ }
29
+ if (value === 'true') return true;
30
+ if (value === 'false') return false;
31
+ return null;
32
+ }
33
+
34
+ function parseLimitOffset(url) {
35
+ const limitRaw = Number(url.searchParams.get('limit'));
36
+ const offsetRaw = Number(url.searchParams.get('offset'));
37
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 100) : 20;
38
+ const offset = Number.isFinite(offsetRaw) ? Math.max(offsetRaw, 0) : 0;
39
+ return { limit, offset };
40
+ }
41
+
42
+ function parseLimitOffsetStrict(url) {
43
+ const limitRaw = url.searchParams.get('limit');
44
+ const offsetRaw = url.searchParams.get('offset');
45
+
46
+ if (limitRaw !== null && !Number.isFinite(Number(limitRaw))) {
47
+ return { error: 'invalid_limit' };
48
+ }
49
+ if (offsetRaw !== null && !Number.isFinite(Number(offsetRaw))) {
50
+ return { error: 'invalid_offset' };
51
+ }
52
+
53
+ return parseLimitOffset(url);
54
+ }
55
+
56
+ function paginate(items, limit, offset) {
57
+ const total = items.length;
58
+ const paged = items.slice(offset, offset + limit);
59
+ return { items: paged, total };
60
+ }
61
+
62
+ module.exports = {
63
+ readJsonBody,
64
+ parseOptionalBoolean,
65
+ parseLimitOffset,
66
+ parseLimitOffsetStrict,
67
+ paginate,
68
+ };
@@ -0,0 +1,23 @@
1
+ function sendJson(res, status, body) {
2
+ const payload = JSON.stringify(body);
3
+ res.writeHead(status, {
4
+ 'Content-Type': 'application/json; charset=utf-8',
5
+ 'Content-Length': Buffer.byteLength(payload),
6
+ });
7
+ res.end(payload);
8
+ }
9
+
10
+ function sendNoContent(res) {
11
+ res.writeHead(204);
12
+ res.end();
13
+ }
14
+
15
+ function sendMethodNotAllowed(res) {
16
+ sendJson(res, 405, { error: 'method_not_allowed' });
17
+ }
18
+
19
+ module.exports = {
20
+ sendJson,
21
+ sendNoContent,
22
+ sendMethodNotAllowed,
23
+ };
@@ -0,0 +1,99 @@
1
+ function escapeRegExp(value) {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+
5
+ function compilePath(path) {
6
+ if (path === '/') {
7
+ return { regex: /^\/$/, paramNames: [], score: 0 };
8
+ }
9
+
10
+ const segments = path.split('/').filter(Boolean);
11
+ const paramNames = [];
12
+ const pattern = segments
13
+ .map((segment) => {
14
+ if (segment.startsWith(':')) {
15
+ paramNames.push(segment.slice(1));
16
+ return '([^/]+)';
17
+ }
18
+ return escapeRegExp(segment);
19
+ })
20
+ .join('/');
21
+
22
+ const staticCount = segments.filter((segment) => !segment.startsWith(':')).length;
23
+ const score = staticCount * 100 + segments.length;
24
+
25
+ return {
26
+ regex: new RegExp(`^/${pattern}$`),
27
+ paramNames,
28
+ score,
29
+ };
30
+ }
31
+
32
+ function createRouter() {
33
+ const routes = [];
34
+
35
+ function add(method, path, handler) {
36
+ const compiled = compilePath(path);
37
+ routes.push({
38
+ method,
39
+ path,
40
+ handler,
41
+ regex: compiled.regex,
42
+ paramNames: compiled.paramNames,
43
+ score: compiled.score,
44
+ order: routes.length,
45
+ });
46
+ }
47
+
48
+ async function handle(req, res, url, deps) {
49
+ const pathname = url.pathname;
50
+ const matches = [];
51
+
52
+ for (const route of routes) {
53
+ const match = route.regex.exec(pathname);
54
+ if (!match) {
55
+ continue;
56
+ }
57
+
58
+ const params = {};
59
+ route.paramNames.forEach((name, index) => {
60
+ params[name] = match[index + 1];
61
+ });
62
+
63
+ matches.push({ route, params });
64
+ }
65
+
66
+ if (!matches.length) {
67
+ return { handled: false };
68
+ }
69
+
70
+ let bestScore = -1;
71
+ for (const match of matches) {
72
+ if (match.route.score > bestScore) {
73
+ bestScore = match.route.score;
74
+ }
75
+ }
76
+
77
+ const bestMatches = matches.filter((match) => match.route.score === bestScore);
78
+ const methodCandidates = bestMatches.filter((match) => match.route.method === req.method);
79
+ const methodMatch = methodCandidates.sort((a, b) => a.route.order - b.route.order)[0];
80
+
81
+ if (methodMatch) {
82
+ await methodMatch.route.handler(req, res, {
83
+ url,
84
+ params: methodMatch.params,
85
+ deps,
86
+ pathname,
87
+ });
88
+ return { handled: true };
89
+ }
90
+
91
+ return { methodNotAllowed: true };
92
+ }
93
+
94
+ return { add, handle };
95
+ }
96
+
97
+ module.exports = {
98
+ createRouter,
99
+ };
@@ -23,7 +23,15 @@ function installPlaywright(logger) {
23
23
  });
24
24
  child.stdout?.on('data', (chunk) => logger?.(chunk.toString()));
25
25
  child.stderr?.on('data', (chunk) => logger?.(chunk.toString()));
26
- child.on('error', reject);
26
+ child.on('error', (error) => {
27
+ if (error?.code === 'ENOENT') {
28
+ const notInstalled = new Error('playwright_not_installed');
29
+ notInstalled.cause = error;
30
+ reject(notInstalled);
31
+ return;
32
+ }
33
+ reject(error);
34
+ });
27
35
  child.on('exit', (code) => {
28
36
  if (code === 0) resolve();
29
37
  else reject(new Error(`npm_install_failed:${code ?? 'unknown'}`));
@@ -43,7 +51,14 @@ async function ensurePlaywrightInstalled(options = {}) {
43
51
  installPromise = null;
44
52
  });
45
53
  }
46
- await installPromise;
54
+ try {
55
+ await installPromise;
56
+ } catch (error) {
57
+ if (error?.message === 'playwright_not_installed') {
58
+ throw error;
59
+ }
60
+ throw new Error('playwright_not_installed');
61
+ }
47
62
  }
48
63
 
49
64
  async function loadPlaywright(options = {}) {
@@ -0,0 +1,89 @@
1
+ const { getConfigPath } = require('../storage/paths');
2
+ const { readJson, writeJson } = require('../storage/fileStore');
3
+ const { readJsonBody } = require('../http/request');
4
+ const { sendJson } = require('../http/response');
5
+
6
+ const DEFAULT_CONFIG = {
7
+ model: {
8
+ name: '',
9
+ version: '',
10
+ provider: '',
11
+ },
12
+ aiModels: [],
13
+ selectedModelId: '',
14
+ templates: {
15
+ styleguide: '',
16
+ components: '',
17
+ },
18
+ extractOptions: {
19
+ includeRules: true,
20
+ includeComponents: true,
21
+ },
22
+ updatedAt: null,
23
+ };
24
+
25
+ async function loadConfig(storage) {
26
+ const configPath = getConfigPath(storage.dataDir);
27
+ try {
28
+ return await readJson(configPath);
29
+ } catch (error) {
30
+ if (error.code === 'ENOENT') {
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ function normalizeConfig(input) {
38
+ const now = new Date().toISOString();
39
+ const model = input?.model || {};
40
+ const templates = input?.templates || {};
41
+ const extractOptions = input?.extractOptions || {};
42
+ const aiModels = Array.isArray(input?.aiModels) ? input.aiModels : [];
43
+ const selectedModelId = typeof input?.selectedModelId === 'string' ? input.selectedModelId : '';
44
+ return {
45
+ model: {
46
+ ...DEFAULT_CONFIG.model,
47
+ ...model,
48
+ },
49
+ aiModels,
50
+ selectedModelId,
51
+ templates: {
52
+ ...DEFAULT_CONFIG.templates,
53
+ ...templates,
54
+ },
55
+ extractOptions: {
56
+ ...DEFAULT_CONFIG.extractOptions,
57
+ ...extractOptions,
58
+ },
59
+ updatedAt: now,
60
+ };
61
+ }
62
+
63
+ async function handleConfigGet(res, storage) {
64
+ const config = await loadConfig(storage);
65
+ sendJson(res, 200, config);
66
+ }
67
+
68
+ async function handleConfigPut(req, res, storage) {
69
+ const body = await readJsonBody(req, res);
70
+ if (!body) {
71
+ return;
72
+ }
73
+ if (typeof body !== 'object' || Array.isArray(body)) {
74
+ return sendJson(res, 400, { error: 'invalid_config' });
75
+ }
76
+ const config = normalizeConfig(body);
77
+ await writeJson(getConfigPath(storage.dataDir), config);
78
+ return sendJson(res, 200, config);
79
+ }
80
+
81
+ function registerConfigRoutes(router, deps) {
82
+ const { storage } = deps;
83
+ router.add('GET', '/api/config', (req, res) => handleConfigGet(res, storage));
84
+ router.add('PUT', '/api/config', (req, res) => handleConfigPut(req, res, storage));
85
+ }
86
+
87
+ module.exports = {
88
+ registerConfigRoutes,
89
+ };
@@ -0,0 +1,66 @@
1
+ const { sendJson, sendNoContent } = require('../http/response');
2
+ const { readJsonBody, parseLimitOffset, paginate } = require('../http/request');
3
+
4
+ function handleDesignList(res, url, storage) {
5
+ const { limit, offset } = parseLimitOffset(url);
6
+ const designs = storage.listDesigns();
7
+ const { items, total } = paginate(designs, limit, offset);
8
+ sendJson(res, 200, { items, limit, offset, total });
9
+ }
10
+
11
+ async function handleDesignCreate(req, res, storage) {
12
+ const body = await readJsonBody(req, res);
13
+ if (!body) {
14
+ return;
15
+ }
16
+ const design = await storage.createDesign(body);
17
+ sendJson(res, 201, design);
18
+ }
19
+
20
+ async function handleDesignGet(res, storage, designId) {
21
+ const design = await storage.getDesign(designId);
22
+ if (!design) {
23
+ return sendJson(res, 404, { error: 'design_not_found' });
24
+ }
25
+ return sendJson(res, 200, design);
26
+ }
27
+
28
+ async function handleDesignPatch(req, res, storage, designId) {
29
+ const body = await readJsonBody(req, res);
30
+ if (!body) {
31
+ return;
32
+ }
33
+ const design = await storage.updateDesign(designId, body);
34
+ if (!design) {
35
+ return sendJson(res, 404, { error: 'design_not_found' });
36
+ }
37
+ return sendJson(res, 200, design);
38
+ }
39
+
40
+ async function handleDesignDelete(res, storage, designId) {
41
+ const design = await storage.getDesign(designId);
42
+ if (!design) {
43
+ return sendJson(res, 404, { error: 'design_not_found' });
44
+ }
45
+ await storage.deleteDesign(designId);
46
+ return sendNoContent(res);
47
+ }
48
+
49
+ function registerDesignRoutes(router, deps) {
50
+ const { storage } = deps;
51
+ router.add('GET', '/api/designs', (req, res, ctx) => handleDesignList(res, ctx.url, storage));
52
+ router.add('POST', '/api/designs', (req, res) => handleDesignCreate(req, res, storage));
53
+ router.add('GET', '/api/designs/:designId', (req, res, ctx) =>
54
+ handleDesignGet(res, storage, ctx.params.designId)
55
+ );
56
+ router.add('PATCH', '/api/designs/:designId', (req, res, ctx) =>
57
+ handleDesignPatch(req, res, storage, ctx.params.designId)
58
+ );
59
+ router.add('DELETE', '/api/designs/:designId', (req, res, ctx) =>
60
+ handleDesignDelete(res, storage, ctx.params.designId)
61
+ );
62
+ }
63
+
64
+ module.exports = {
65
+ registerDesignRoutes,
66
+ };
@@ -0,0 +1,157 @@
1
+ const { URL } = require('url');
2
+
3
+ const { sendJson } = require('../http/response');
4
+ const { readJsonBody } = require('../http/request');
5
+
6
+ function handleImportStream(req, res, extractionPipeline, url) {
7
+ const jobIdFilter = url.searchParams.get('jobId');
8
+
9
+ res.writeHead(200, {
10
+ 'Content-Type': 'text/event-stream; charset=utf-8',
11
+ 'Cache-Control': 'no-cache',
12
+ Connection: 'keep-alive',
13
+ });
14
+ res.write(`event: connected\ndata: ${JSON.stringify({ status: 'ok' })}\n\n`);
15
+
16
+ const unsubscribe = extractionPipeline.onProgress((event) => {
17
+ if (jobIdFilter && event.job.id !== jobIdFilter) {
18
+ return;
19
+ }
20
+ res.write(`event: ${event.event}\ndata: ${JSON.stringify(event)}\n\n`);
21
+ });
22
+
23
+ req.on('close', () => {
24
+ unsubscribe();
25
+ });
26
+ }
27
+
28
+ function handleImportJobs(res, extractionPipeline) {
29
+ sendJson(res, 200, { jobs: extractionPipeline.listJobs() });
30
+ }
31
+
32
+ function handleImportJob(res, extractionPipeline, jobId) {
33
+ const job = extractionPipeline.getJob(jobId);
34
+ if (!job) {
35
+ return sendJson(res, 404, { error: 'job_not_found' });
36
+ }
37
+ return sendJson(res, 200, { job });
38
+ }
39
+
40
+ async function handleImportBrowser(req, res, extractionPipeline) {
41
+ const body = await readJsonBody(req, res);
42
+ if (!body) {
43
+ return;
44
+ }
45
+
46
+ try {
47
+ const job = extractionPipeline.enqueueImportFromBrowser(body);
48
+ sendJson(res, 202, { job });
49
+ } catch (error) {
50
+ sendJson(res, 400, { error: error.message });
51
+ }
52
+ }
53
+
54
+ async function handleImportUrl(req, res, storage, extractionPipeline, importJobs) {
55
+ const body = await readJsonBody(req, res);
56
+ if (!body) {
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const rawUrl = body.url || '';
62
+ if (!rawUrl) {
63
+ return sendJson(res, 400, { error: 'url_required' });
64
+ }
65
+
66
+ try {
67
+ new URL(rawUrl);
68
+ } catch {
69
+ return sendJson(res, 400, { error: 'invalid_url' });
70
+ }
71
+
72
+ const requestedDesignId = body.designId || null;
73
+ let requestedDesign = null;
74
+ if (requestedDesignId) {
75
+ requestedDesign = await storage.getDesign(requestedDesignId);
76
+ if (!requestedDesign) {
77
+ return sendJson(res, 404, { error: 'design_not_found' });
78
+ }
79
+ }
80
+
81
+ const existing = requestedDesign ? null : storage.listDesigns().find((item) => item.url === rawUrl);
82
+ const now = new Date().toISOString();
83
+ const useAI = !!body?.options?.useAI;
84
+ const hasPromptTemplateId = Object.prototype.hasOwnProperty.call(body?.options || {}, 'promptTemplateId');
85
+ const promptTemplateIdRaw = body?.options?.promptTemplateId;
86
+ const promptTemplateId =
87
+ hasPromptTemplateId && typeof promptTemplateIdRaw === 'string'
88
+ ? promptTemplateIdRaw.trim() || null
89
+ : hasPromptTemplateId
90
+ ? null
91
+ : undefined;
92
+
93
+ let designId = requestedDesign?.id || existing?.id || null;
94
+ if (!designId) {
95
+ let name = rawUrl;
96
+ try {
97
+ const parsed = new URL(rawUrl);
98
+ name = parsed.hostname || rawUrl;
99
+ } catch {}
100
+
101
+ const design = await storage.createDesign({
102
+ name,
103
+ url: rawUrl,
104
+ source: 'script',
105
+ metadata: {
106
+ extractedFrom: 'playwright',
107
+ processingStatus: 'processing',
108
+ processingStartedAt: now,
109
+ processingError: null,
110
+ aiRequested: useAI,
111
+ aiCompleted: false,
112
+ ...(promptTemplateId !== undefined ? { promptTemplateId } : {}),
113
+ tags: [],
114
+ },
115
+ });
116
+ designId = design.id;
117
+ }
118
+
119
+ const job = extractionPipeline.enqueueImportFromUrl({ ...body, designId });
120
+ importJobs.trackImportJob(job.id, designId);
121
+
122
+ await storage.updateDesign(designId, {
123
+ metadata: {
124
+ processingStatus: 'processing',
125
+ processingStartedAt: now,
126
+ processingJobId: job.id,
127
+ processingError: null,
128
+ aiRequested: useAI,
129
+ ...(useAI ? { aiCompleted: false } : {}),
130
+ ...(promptTemplateId !== undefined ? { promptTemplateId } : {}),
131
+ },
132
+ });
133
+
134
+ sendJson(res, 202, { job, designId });
135
+ } catch (error) {
136
+ sendJson(res, 400, { error: error.message });
137
+ }
138
+ }
139
+
140
+ function registerImportRoutes(router, deps) {
141
+ const { storage, extractionPipeline, importJobs } = deps;
142
+ router.add('GET', '/api/import/stream', (req, res, ctx) => handleImportStream(req, res, extractionPipeline, ctx.url));
143
+ router.add('GET', '/api/import/jobs', (req, res) => handleImportJobs(res, extractionPipeline));
144
+ router.add('GET', '/api/import/jobs/:jobId', (req, res, ctx) =>
145
+ handleImportJob(res, extractionPipeline, ctx.params.jobId)
146
+ );
147
+ router.add('POST', '/api/import/browser', (req, res) => handleImportBrowser(req, res, extractionPipeline));
148
+ router.add('POST', '/api/import/url', (req, res) =>
149
+ handleImportUrl(req, res, storage, extractionPipeline, importJobs)
150
+ );
151
+ router.add('POST', '/api/designs/import', (req, res) => handleImportBrowser(req, res, extractionPipeline));
152
+ router.add('POST', '/api/snapshots/import', (req, res) => handleImportBrowser(req, res, extractionPipeline));
153
+ }
154
+
155
+ module.exports = {
156
+ registerImportRoutes,
157
+ };
@@ -0,0 +1,29 @@
1
+ const { registerRootRoutes } = require('./root');
2
+ const { registerImportRoutes } = require('./import');
3
+ const { registerDesignRoutes } = require('./designs');
4
+ const { registerVersionRoutes } = require('./versions');
5
+ const { registerSnapshotRoutes } = require('./snapshots');
6
+ const { registerPreviewRoutes } = require('./previews');
7
+ const { registerTaskRoutes } = require('./tasks');
8
+ const { registerConfigRoutes } = require('./config');
9
+ const { registerPromptTemplateRoutes } = require('./promptTemplates');
10
+ const { registerUiproRoutes } = require('./uipro');
11
+ const { registerScanRoutes } = require('./scanRoutes');
12
+
13
+ function registerRoutes(router, deps) {
14
+ registerRootRoutes(router, deps);
15
+ registerImportRoutes(router, deps);
16
+ registerConfigRoutes(router, deps);
17
+ registerPromptTemplateRoutes(router, deps);
18
+ registerDesignRoutes(router, deps);
19
+ registerVersionRoutes(router, deps);
20
+ registerSnapshotRoutes(router, deps);
21
+ registerPreviewRoutes(router, deps);
22
+ registerTaskRoutes(router, deps);
23
+ registerScanRoutes(router, deps);
24
+ registerUiproRoutes(router, deps);
25
+ }
26
+
27
+ module.exports = {
28
+ registerRoutes,
29
+ };