Skip to content

Commit 1a866eb

Browse files
committed
add app-factory...
reimplements existing app.ts, wrapped in executing class
1 parent fb520b9 commit 1a866eb

File tree

2 files changed

+309
-4
lines changed

2 files changed

+309
-4
lines changed

express-api-todo.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,27 @@
1414
- Full JSDoc documentation for all interfaces and properties
1515

1616
### 1.2 Extract App Creation Logic
17-
- [ ] **Keep existing `src/app.ts` unchanged** (platform usage)
18-
- [ ] Create `src/app-factory.ts` with shared Express app creation logic
19-
- [ ] Extract common app setup code from existing `src/app.ts`
20-
- [ ] Ensure both standalone and programmatic modes use same logic
17+
- [x] **Keep existing `src/app.ts` unchanged** (platform usage)
18+
- [x] Create `src/app-factory.ts` with shared Express app creation logic
19+
- [x] Extract common app setup code from existing `src/app.ts`
20+
- [x] Ensure both standalone and programmatic modes use same logic
21+
22+
**Summary**: Created `app-factory.ts` with shared Express app creation logic:
23+
- `createExpressApp(config)` - Accepts both ExpressServerConfig and EnvironmentConfig
24+
- `initializeServices()` - Extracted async initialization logic for background services
25+
- Type guards to handle dual configuration formats (programmatic vs env vars)
26+
- Config conversion utilities to bridge between formats
27+
- All routes and middleware extracted from original app.ts
28+
- VueClientRequest interface moved to factory for shared usage
29+
30+
### 1.2b Refactor Existing App to Use Factory
31+
- [ ] Refactor `src/app.ts` to use `createExpressApp()` from app-factory
32+
- [ ] Replace duplicated middleware/routes with factory function call
33+
- [ ] Use `initializeServices()` instead of inline init() function
34+
- [ ] Ensure existing platform behavior unchanged
35+
- [ ] Test that `yarn dev` still works correctly
36+
37+
**Note**: This eliminates code duplication and ensures both standalone and programmatic modes use identical logic.
2138

2239
### 1.3 Create Programmatic Server Class
2340
- [ ] Create `src/server.ts` with `SkuilderExpressServer` class
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import {
2+
ServerRequestType as RequestEnum,
3+
ServerRequest,
4+
prepareNote55,
5+
} from '@vue-skuilder/common';
6+
import { CourseLookup } from '@vue-skuilder/db';
7+
import cookieParser from 'cookie-parser';
8+
import cors from 'cors';
9+
import type { Request, Response } from 'express';
10+
import express from 'express';
11+
import morgan from 'morgan';
12+
import Nano from 'nano';
13+
import PostProcess from './attachment-preprocessing/index.js';
14+
import {
15+
ClassroomCreationQueue,
16+
ClassroomJoinQueue,
17+
ClassroomLeaveQueue,
18+
} from './client-requests/classroom-requests.js';
19+
import {
20+
CourseCreationQueue,
21+
initCourseDBDesignDocInsert,
22+
} from './client-requests/course-requests.js';
23+
import { packCourse } from './client-requests/pack-requests.js';
24+
import { requestIsAuthenticated } from './couchdb/authentication.js';
25+
import CouchDB, {
26+
useOrCreateCourseDB,
27+
useOrCreateDB,
28+
} from './couchdb/index.js';
29+
import { classroomDbDesignDoc } from './design-docs.js';
30+
import logger from './logger.js';
31+
import logsRouter from './routes/logs.js';
32+
import type { ExpressServerConfig, EnvironmentConfig } from './types.js';
33+
34+
export interface VueClientRequest extends express.Request {
35+
body: ServerRequest;
36+
}
37+
38+
/**
39+
* Configuration options for creating an Express app.
40+
* Can be provided either as ExpressServerConfig (programmatic) or EnvironmentConfig (env vars).
41+
*/
42+
export type AppConfig = ExpressServerConfig | EnvironmentConfig;
43+
44+
/**
45+
* Type guard to determine if config is ExpressServerConfig (programmatic usage)
46+
*/
47+
function isExpressServerConfig(config: AppConfig): config is ExpressServerConfig {
48+
return 'couchdb' in config && typeof config.couchdb === 'object';
49+
}
50+
51+
/**
52+
* Convert ExpressServerConfig to environment-style config for internal usage
53+
*/
54+
function convertToEnvConfig(config: ExpressServerConfig): EnvironmentConfig {
55+
return {
56+
COUCHDB_SERVER: config.couchdb.server,
57+
COUCHDB_PROTOCOL: config.couchdb.protocol,
58+
COUCHDB_ADMIN: config.couchdb.username,
59+
COUCHDB_PASSWORD: config.couchdb.password,
60+
VERSION: config.version,
61+
NODE_ENV: config.nodeEnv || 'development',
62+
};
63+
}
64+
65+
/**
66+
* Create and configure Express application with all routes and middleware.
67+
* This is the shared logic used by both standalone and programmatic modes.
68+
*/
69+
export function createExpressApp(config: AppConfig): express.Application {
70+
const app = express();
71+
72+
// Normalize config to environment format for internal usage
73+
const envConfig = isExpressServerConfig(config)
74+
? convertToEnvConfig(config)
75+
: config;
76+
77+
// Configure CORS - use config if available, otherwise defaults
78+
const corsOptions = isExpressServerConfig(config) && config.cors
79+
? config.cors
80+
: { credentials: true, origin: true };
81+
82+
// Middleware setup
83+
app.use(cookieParser());
84+
app.use(express.json());
85+
app.use(cors(corsOptions));
86+
app.use(
87+
morgan('combined', {
88+
stream: { write: (message: string) => logger.info(message.trim()) },
89+
})
90+
);
91+
app.use('/logs', logsRouter);
92+
93+
// Routes
94+
app.get('/courses', (_req: Request, res: Response) => {
95+
void (async () => {
96+
try {
97+
const courses = await CourseLookup.allCourseWare();
98+
res.send(courses.map((c) => `${c._id} - ${c.name}`));
99+
} catch (error) {
100+
logger.error('Error fetching courses:', error);
101+
res.status(500).send('Failed to fetch courses');
102+
}
103+
})();
104+
});
105+
106+
app.get('/course/:courseID/config', (req: Request, res: Response) => {
107+
void (async () => {
108+
try {
109+
const courseDB = await useOrCreateCourseDB(req.params.courseID);
110+
const cfg = await courseDB.get('CourseConfig'); // [ ] pull courseConfig docName into global const
111+
112+
res.json(cfg);
113+
} catch (error) {
114+
logger.error('Error fetching course config:', error);
115+
res.status(500).send('Failed to fetch course config');
116+
}
117+
})();
118+
});
119+
120+
app.delete('/course/:courseID', (req: Request, res: Response) => {
121+
void (async () => {
122+
try {
123+
logger.info(`Delete request made on course ${req.params.courseID}...`);
124+
const auth = await requestIsAuthenticated(req);
125+
if (auth) {
126+
logger.info(`\tAuthenticated delete request made...`);
127+
const dbResp = await CouchDB.db.destroy(
128+
`coursedb-${req.params.courseID}`
129+
);
130+
if (!dbResp.ok) {
131+
res.json({ success: false, error: dbResp });
132+
return;
133+
}
134+
const delResp = await CourseLookup.delete(req.params.courseID);
135+
136+
if (delResp.ok) {
137+
res.json({ success: true });
138+
} else {
139+
res.json({ success: false, error: delResp });
140+
}
141+
} else {
142+
res.json({ success: false, error: 'Not authenticated' });
143+
}
144+
} catch (error) {
145+
logger.error('Error deleting course:', error);
146+
res.status(500).json({ success: false, error: 'Failed to delete course' });
147+
}
148+
})();
149+
});
150+
151+
async function postHandler(
152+
req: VueClientRequest,
153+
res: express.Response
154+
): Promise<void> {
155+
const auth = await requestIsAuthenticated(req);
156+
if (auth) {
157+
const body = req.body;
158+
logger.info(
159+
`Authorized ${
160+
body.type ? body.type : '[unspecified request type]'
161+
} request made...`
162+
);
163+
164+
if (body.type === RequestEnum.CREATE_CLASSROOM) {
165+
const id: number = ClassroomCreationQueue.addRequest(body.data);
166+
body.response = await ClassroomCreationQueue.getResult(id);
167+
res.json(body.response);
168+
} else if (body.type === RequestEnum.DELETE_CLASSROOM) {
169+
// [ ] add delete classroom request
170+
} else if (body.type === RequestEnum.JOIN_CLASSROOM) {
171+
const id: number = ClassroomJoinQueue.addRequest(body.data);
172+
body.response = await ClassroomJoinQueue.getResult(id);
173+
res.json(body.response);
174+
} else if (body.type === RequestEnum.LEAVE_CLASSROOM) {
175+
const id: number = ClassroomLeaveQueue.addRequest({
176+
username: req.body.user,
177+
...body.data,
178+
});
179+
body.response = await ClassroomLeaveQueue.getResult(id);
180+
res.json(body.response);
181+
} else if (body.type === RequestEnum.CREATE_COURSE) {
182+
const id: number = CourseCreationQueue.addRequest(body.data);
183+
body.response = await CourseCreationQueue.getResult(id);
184+
res.json(body.response);
185+
} else if (body.type === RequestEnum.ADD_COURSE_DATA) {
186+
const payload = prepareNote55(
187+
body.data.courseID,
188+
body.data.codeCourse,
189+
body.data.shape,
190+
body.data.data,
191+
body.data.author,
192+
body.data.tags,
193+
body.data.uploads
194+
);
195+
CouchDB.use(`coursedb-${body.data.courseID}`)
196+
.insert(payload as Nano.MaybeDocument)
197+
.then((r) => {
198+
logger.info(`\t\t\tCouchDB insert result: ${JSON.stringify(r)}`);
199+
res.json(r);
200+
})
201+
.catch((e) => {
202+
logger.info(`\t\t\tCouchDB insert error: ${JSON.stringify(e)}`);
203+
res.json(e);
204+
});
205+
} else if (body.type === RequestEnum.PACK_COURSE) {
206+
if (envConfig.NODE_ENV !== 'studio') {
207+
logger.info(
208+
`\tPACK_COURSE request received in production mode, but this is not supported!`
209+
);
210+
res.status(400);
211+
res.statusMessage = 'Packing courses is not supported in production mode.';
212+
res.send();
213+
return;
214+
}
215+
216+
body.response = await packCourse({
217+
courseId: body.courseId,
218+
outputPath: body.outputPath
219+
});
220+
res.json(body.response);
221+
}
222+
} else {
223+
logger.info(`\tREQUEST UNAUTHORIZED!`);
224+
res.status(401);
225+
res.statusMessage = 'Unauthorized';
226+
res.send();
227+
}
228+
}
229+
230+
app.post('/', (req: Request, res: Response) => {
231+
void postHandler(req, res);
232+
});
233+
234+
app.get('/version', (_req: Request, res: Response) => {
235+
res.send(envConfig.VERSION);
236+
});
237+
238+
app.get('/', (_req: Request, res: Response) => {
239+
let status = `Express service is running.\nVersion: ${envConfig.VERSION}\n`;
240+
241+
CouchDB.session()
242+
.then((s) => {
243+
if (s.ok) {
244+
status += 'Couchdb is running.\n';
245+
} else {
246+
status += 'Couchdb session is NOT ok.\n';
247+
}
248+
})
249+
.catch((e) => {
250+
status += `Problems in the couch session! ${JSON.stringify(e)}`;
251+
})
252+
.finally(() => {
253+
res.send(status);
254+
});
255+
});
256+
257+
return app;
258+
}
259+
260+
/**
261+
* Initialize background services and database connections.
262+
* This should be called after the server starts listening.
263+
*/
264+
export async function initializeServices(): Promise<void> {
265+
try {
266+
// start the change-listener that does post-processing on user
267+
// media uploads
268+
void PostProcess();
269+
270+
void initCourseDBDesignDocInsert();
271+
272+
void useOrCreateDB('classdb-lookup');
273+
try {
274+
await (
275+
await useOrCreateDB('coursedb')
276+
).insert(
277+
{
278+
validate_doc_update: classroomDbDesignDoc,
279+
} as Nano.MaybeDocument,
280+
'_design/_auth'
281+
);
282+
} catch (e) {
283+
logger.info(`Error: ${e}`);
284+
}
285+
} catch (e) {
286+
logger.info(`Error: ${JSON.stringify(e)}`);
287+
}
288+
}

0 commit comments

Comments
 (0)