proteum 2.2.3 → 2.2.7

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.
@@ -0,0 +1,513 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const http = require('node:http');
4
+ const net = require('node:net');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const test = require('node:test');
9
+
10
+ const coreRoot = path.resolve(__dirname, '..');
11
+ const cliBin = path.join(coreRoot, 'cli', 'bin.js');
12
+
13
+ const sleep = async (durationMs) => await new Promise((resolve) => setTimeout(resolve, durationMs));
14
+
15
+ const writeFile = (filepath, content) => {
16
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
17
+ fs.writeFileSync(filepath, content);
18
+ };
19
+
20
+ const createSymlink = (target, linkPath) => {
21
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
22
+ fs.symlinkSync(target, linkPath, 'dir');
23
+ };
24
+
25
+ const canListen = async (port) =>
26
+ await new Promise((resolve) => {
27
+ const server = net.createServer();
28
+
29
+ server.once('error', () => resolve(false));
30
+ server.once('listening', () => {
31
+ server.close(() => resolve(true));
32
+ });
33
+ server.listen(port, '127.0.0.1');
34
+ });
35
+
36
+ const resolvePortPair = async () => {
37
+ for (let port = 34000; port < 39000; port += 2) {
38
+ if ((await canListen(port)) && (await canListen(port + 1))) return port;
39
+ }
40
+
41
+ throw new Error('Unable to find a free port pair for the dev server and HMR stream.');
42
+ };
43
+
44
+ const walkFiles = (root, predicate, output = []) => {
45
+ if (!fs.existsSync(root)) return output;
46
+
47
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
48
+ const filepath = path.join(root, entry.name);
49
+
50
+ if (entry.isDirectory()) {
51
+ walkFiles(filepath, predicate, output);
52
+ continue;
53
+ }
54
+
55
+ if (predicate(filepath)) output.push(filepath);
56
+ }
57
+
58
+ return output;
59
+ };
60
+
61
+ const findAssetContaining = (appRoot, extension, marker) => {
62
+ const publicRoot = path.join(appRoot, 'dev', 'public');
63
+ const candidates = walkFiles(publicRoot, (filepath) => filepath.endsWith(extension));
64
+
65
+ return candidates.find((filepath) => fs.readFileSync(filepath, 'utf8').includes(marker));
66
+ };
67
+
68
+ const waitForAssetContaining = async (appRoot, extension, marker, timeoutMs = 60000) => {
69
+ const deadline = Date.now() + timeoutMs;
70
+
71
+ while (Date.now() < deadline) {
72
+ const filepath = findAssetContaining(appRoot, extension, marker);
73
+ if (filepath) return filepath;
74
+ await sleep(250);
75
+ }
76
+
77
+ throw new Error(`Timed out waiting for ${extension} asset containing ${marker}.`);
78
+ };
79
+
80
+ const waitForSessionReady = async (sessionFile, child, getOutput, timeoutMs = 90000) => {
81
+ const deadline = Date.now() + timeoutMs;
82
+
83
+ while (Date.now() < deadline) {
84
+ if (child.exitCode !== null) {
85
+ throw new Error(`proteum dev exited early with ${child.exitCode}.\n${getOutput()}`);
86
+ }
87
+
88
+ try {
89
+ const session = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
90
+ if (session.state === 'ready' && session.publicUrl) return session;
91
+ } catch {}
92
+
93
+ await sleep(250);
94
+ }
95
+
96
+ throw new Error(`Timed out waiting for proteum dev to become ready.\n${getOutput()}`);
97
+ };
98
+
99
+ const connectToReloadStream = async (hmrPort) => {
100
+ let request;
101
+
102
+ const eventPromise = new Promise((resolve, reject) => {
103
+ request = http.request(
104
+ {
105
+ hostname: '127.0.0.1',
106
+ port: hmrPort,
107
+ path: '/__proteum_hmr',
108
+ method: 'GET',
109
+ headers: {
110
+ Accept: 'text/event-stream',
111
+ },
112
+ },
113
+ (response) => {
114
+ response.setEncoding('utf8');
115
+ response.on('data', (chunk) => {
116
+ for (const line of chunk.split('\n')) {
117
+ if (!line.startsWith('data:')) continue;
118
+
119
+ try {
120
+ const event = JSON.parse(line.slice('data:'.length).trim());
121
+ if (event.type === 'reload') {
122
+ resolve(event);
123
+ request.destroy();
124
+ }
125
+ } catch (error) {
126
+ reject(error);
127
+ }
128
+ }
129
+ });
130
+ },
131
+ );
132
+
133
+ request.on('error', reject);
134
+ request.end();
135
+ });
136
+
137
+ await sleep(250);
138
+
139
+ return {
140
+ waitForReload: async (timeoutMs = 60000) =>
141
+ await new Promise((resolve, reject) => {
142
+ const timeout = setTimeout(() => {
143
+ reject(new Error('Timed out waiting for an HMR reload event.'));
144
+ }, timeoutMs);
145
+
146
+ eventPromise.then(
147
+ (event) => {
148
+ clearTimeout(timeout);
149
+ resolve(event);
150
+ },
151
+ (error) => {
152
+ clearTimeout(timeout);
153
+ reject(error);
154
+ },
155
+ );
156
+ }),
157
+ close: () => request?.destroy(),
158
+ };
159
+ };
160
+
161
+ const createSharedIndexSource = (marker) => `import React from 'react';
162
+ import './styles.css';
163
+
164
+ export const SharedMarker = () => {
165
+ return <strong className="shared-marker shared-style-marker">${marker}</strong>;
166
+ };
167
+ `;
168
+
169
+ const createSharedStyleSource = (marker) => `.shared-style-marker {
170
+ --shared-watch-marker: "${marker}";
171
+ color: rgb(25, 45, 65);
172
+ }
173
+ `;
174
+
175
+ const createFixture = (root, port) => {
176
+ const appRoot = path.join(root, 'app');
177
+ const sharedRoot = path.join(root, 'shared');
178
+
179
+ fs.mkdirSync(path.join(appRoot, 'public'), { recursive: true });
180
+ fs.mkdirSync(path.join(appRoot, 'client', 'assets', 'identity'), { recursive: true });
181
+ fs.mkdirSync(path.join(appRoot, 'client', 'pages'), { recursive: true });
182
+ fs.mkdirSync(path.join(appRoot, 'server', 'config'), { recursive: true });
183
+ fs.mkdirSync(sharedRoot, { recursive: true });
184
+
185
+ writeFile(
186
+ path.join(appRoot, 'package.json'),
187
+ JSON.stringify(
188
+ {
189
+ name: 'proteum-transpile-watch-fixture',
190
+ private: true,
191
+ version: '0.0.0',
192
+ dependencies: {
193
+ '@test/shared': 'file:../shared',
194
+ proteum: `file:${coreRoot}`,
195
+ },
196
+ },
197
+ null,
198
+ 4,
199
+ ) + '\n',
200
+ );
201
+ writeFile(
202
+ path.join(appRoot, '.env'),
203
+ `ENV_NAME=local
204
+ ENV_PROFILE=dev
205
+ PORT=${port}
206
+ URL=http://localhost:${port}
207
+ URL_INTERNAL=http://localhost:${port}
208
+ `,
209
+ );
210
+ writeFile(
211
+ path.join(appRoot, 'identity.config.ts'),
212
+ `import { Application } from 'proteum/config';
213
+
214
+ export default Application.identity({
215
+ name: 'Transpile Watch Fixture',
216
+ identifier: 'TranspileWatchFixture',
217
+ description: 'Proteum transpile watcher fixture.',
218
+ author: {
219
+ name: 'Proteum',
220
+ url: 'localhost',
221
+ email: 'team@example.com',
222
+ },
223
+ social: {},
224
+ language: 'en',
225
+ locale: 'en-US',
226
+ maincolor: 'white',
227
+ iconsPack: 'light',
228
+ web: {
229
+ title: 'Transpile Watch Fixture',
230
+ titleSuffix: 'Transpile Watch Fixture',
231
+ fullTitle: 'Transpile Watch Fixture',
232
+ description: 'Proteum transpile watcher fixture.',
233
+ version: '0.0.0',
234
+ },
235
+ });
236
+ `,
237
+ );
238
+ writeFile(
239
+ path.join(appRoot, 'proteum.config.ts'),
240
+ `import { Application } from 'proteum/config';
241
+
242
+ export default Application.setup({
243
+ transpile: ['@test/shared'],
244
+ connect: {},
245
+ });
246
+ `,
247
+ );
248
+ writeFile(
249
+ path.join(appRoot, 'client', 'assets', 'identity', 'logo.svg'),
250
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
251
+ <rect width="64" height="64" rx="12" fill="#111827"/>
252
+ <path d="M18 42V22h15c8 0 13 4 13 10s-5 10-13 10H18Zm8-7h7c4 0 6-1 6-3s-2-3-6-3h-7v6Z" fill="#ffffff"/>
253
+ </svg>
254
+ `,
255
+ );
256
+ writeFile(
257
+ path.join(appRoot, 'client', 'tsconfig.json'),
258
+ `{
259
+ "extends": "../node_modules/proteum/tsconfig.common.json",
260
+ "compilerOptions": {
261
+ "rootDir": "..",
262
+ "baseUrl": "..",
263
+ "jsx": "react-jsx",
264
+ "jsxImportSource": "preact",
265
+ "paths": {
266
+ "@client/*": ["./node_modules/proteum/client/*"],
267
+ "@common/*": ["./node_modules/proteum/common/*"],
268
+ "@server/*": ["./node_modules/proteum/server/*"],
269
+ "@/client/context": ["./.proteum/client/context.ts"],
270
+ "@generated/client/*": ["./.proteum/client/*"],
271
+ "@generated/common/*": ["./.proteum/common/*"],
272
+ "@generated/server/*": ["./.proteum/server/*"],
273
+ "@/*": ["./*"],
274
+ "react": ["./node_modules/preact/compat"],
275
+ "react-dom/client": ["./node_modules/preact/compat/client"],
276
+ "react-dom/test-utils": ["./node_modules/preact/test-utils"],
277
+ "react-dom": ["./node_modules/preact/compat"],
278
+ "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"]
279
+ }
280
+ },
281
+ "include": [".", "../server/index.ts"]
282
+ }
283
+ `,
284
+ );
285
+ writeFile(
286
+ path.join(appRoot, 'server', 'tsconfig.json'),
287
+ `{
288
+ "extends": "../node_modules/proteum/tsconfig.common.json",
289
+ "compilerOptions": {
290
+ "rootDir": "..",
291
+ "baseUrl": "..",
292
+ "jsx": "react-jsx",
293
+ "jsxImportSource": "preact",
294
+ "moduleSuffixes": [".ssr", ""],
295
+ "paths": {
296
+ "@client/*": ["./node_modules/proteum/client/*"],
297
+ "@common/*": ["./node_modules/proteum/common/*"],
298
+ "@server/*": ["./node_modules/proteum/server/*"],
299
+ "@/client/context": ["./.proteum/client/context.ts"],
300
+ "@generated/client/*": ["./.proteum/client/*"],
301
+ "@generated/common/*": ["./.proteum/common/*"],
302
+ "@generated/server/*": ["./.proteum/server/*"],
303
+ "@/*": ["./*"],
304
+ "react": ["./node_modules/preact/compat"],
305
+ "react-dom/client": ["./node_modules/preact/compat/client"],
306
+ "react-dom/test-utils": ["./node_modules/preact/test-utils"],
307
+ "react-dom": ["./node_modules/preact/compat"],
308
+ "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"]
309
+ }
310
+ },
311
+ "include": [".", "../identity.config.ts", "../proteum.config.ts", "../server/index.ts"]
312
+ }
313
+ `,
314
+ );
315
+ writeFile(
316
+ path.join(appRoot, 'server', 'config', 'app.ts'),
317
+ `import { type ServiceConfig } from '@server/app';
318
+ import AppContainer from '@server/app/container';
319
+ import Router from '@server/services/router';
320
+
321
+ type RouterBaseConfig = Omit<ServiceConfig<typeof Router>, 'plugins'>;
322
+
323
+ const currentDomain = AppContainer.Environment.router.currentDomain;
324
+ const currentUrl = new URL(currentDomain);
325
+
326
+ export const routerBaseConfig = {
327
+ currentDomain,
328
+ http: {
329
+ domain: currentUrl.hostname,
330
+ port: AppContainer.Environment.router.port,
331
+ ssl: currentUrl.protocol === 'https:',
332
+ upload: {
333
+ maxSize: '10mb',
334
+ },
335
+ csp: {
336
+ scripts: [],
337
+ },
338
+ },
339
+ context: () => ({}),
340
+ } satisfies RouterBaseConfig;
341
+ `,
342
+ );
343
+ writeFile(
344
+ path.join(appRoot, 'server', 'index.ts'),
345
+ `import { Application } from '@server/app';
346
+ import Router from '@server/services/router';
347
+ import SchemaRouter from '@server/services/schema/router';
348
+
349
+ import * as appConfig from '@/server/config/app';
350
+
351
+ export default class TranspileWatchFixture extends Application {
352
+ public Router = new Router(
353
+ this,
354
+ {
355
+ ...appConfig.routerBaseConfig,
356
+ plugins: {
357
+ schema: new SchemaRouter({}, this),
358
+ },
359
+ },
360
+ this,
361
+ );
362
+ }
363
+ `,
364
+ );
365
+ writeFile(
366
+ path.join(appRoot, 'client', 'index.ts'),
367
+ `import ClientApplication from '@client/app';
368
+ import Router from '@client/services/router';
369
+
370
+ export default class TranspileWatchClient extends ClientApplication {
371
+ public Router = new Router(this, {
372
+ preload: [],
373
+ context: () => ({}),
374
+ });
375
+
376
+ public boot() {}
377
+ public handleUpdate() {}
378
+ public handleError(error: Error) {
379
+ throw error;
380
+ }
381
+ }
382
+ `,
383
+ );
384
+ writeFile(
385
+ path.join(appRoot, 'client', 'pages', 'index.tsx'),
386
+ `import Router from '@/client/router';
387
+ import { SharedMarker } from '@test/shared';
388
+
389
+ Router.page(
390
+ '/',
391
+ {
392
+ auth: false,
393
+ layout: false,
394
+ },
395
+ null,
396
+ () => {
397
+ return (
398
+ <main>
399
+ <SharedMarker />
400
+ </main>
401
+ );
402
+ },
403
+ );
404
+ `,
405
+ );
406
+
407
+ writeFile(
408
+ path.join(sharedRoot, 'package.json'),
409
+ JSON.stringify(
410
+ {
411
+ name: '@test/shared',
412
+ version: '0.0.0',
413
+ private: true,
414
+ main: './index.tsx',
415
+ sideEffects: true,
416
+ },
417
+ null,
418
+ 4,
419
+ ) + '\n',
420
+ );
421
+ writeFile(path.join(sharedRoot, 'index.tsx'), createSharedIndexSource('SCRIPT_MARKER_INITIAL'));
422
+ writeFile(path.join(sharedRoot, 'styles.css'), createSharedStyleSource('STYLE_MARKER_INITIAL'));
423
+
424
+ createSymlink(coreRoot, path.join(appRoot, 'node_modules', 'proteum'));
425
+ createSymlink(sharedRoot, path.join(appRoot, 'node_modules', '@test', 'shared'));
426
+
427
+ return {
428
+ appRoot,
429
+ sharedRoot,
430
+ };
431
+ };
432
+
433
+ const stopDevServer = async (child) => {
434
+ if (child.exitCode !== null) return;
435
+
436
+ child.kill('SIGTERM');
437
+
438
+ await new Promise((resolve) => {
439
+ const timeout = setTimeout(() => {
440
+ if (child.exitCode === null) child.kill('SIGKILL');
441
+ resolve();
442
+ }, 10000);
443
+
444
+ child.once('exit', () => {
445
+ clearTimeout(timeout);
446
+ resolve();
447
+ });
448
+ });
449
+ };
450
+
451
+ test('proteum dev invalidates client assets and reloads for transpiled package scripts and styles', { timeout: 180000 }, async () => {
452
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-transpile-watch-'));
453
+ const port = await resolvePortPair();
454
+ const { appRoot, sharedRoot } = createFixture(root, port);
455
+ const sessionFile = path.join(appRoot, 'var', 'run', 'proteum', 'dev', 'transpile-watch-test.json');
456
+ let output = '';
457
+
458
+ const child = spawn(
459
+ process.execPath,
460
+ [cliBin, 'dev', '--cwd', appRoot, '--port', String(port), '--session-file', sessionFile, '--no-cache', '--verbose'],
461
+ {
462
+ cwd: appRoot,
463
+ env: {
464
+ ...process.env,
465
+ FORCE_COLOR: '0',
466
+ NODE_ENV: 'development',
467
+ },
468
+ stdio: ['ignore', 'pipe', 'pipe'],
469
+ },
470
+ );
471
+
472
+ child.stdout.on('data', (chunk) => {
473
+ output += chunk.toString();
474
+ });
475
+ child.stderr.on('data', (chunk) => {
476
+ output += chunk.toString();
477
+ });
478
+
479
+ try {
480
+ await waitForSessionReady(sessionFile, child, () => output);
481
+
482
+ const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
483
+ const initialScriptContent = fs.readFileSync(initialScriptAsset, 'utf8');
484
+ const scriptReloadStream = await connectToReloadStream(port + 1);
485
+
486
+ writeFile(path.join(sharedRoot, 'index.tsx'), createSharedIndexSource('SCRIPT_MARKER_UPDATED'));
487
+
488
+ const updatedScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_UPDATED');
489
+ const scriptReloadEvent = await scriptReloadStream.waitForReload();
490
+ scriptReloadStream.close();
491
+
492
+ assert.equal(updatedScriptAsset, initialScriptAsset);
493
+ assert.notEqual(fs.readFileSync(updatedScriptAsset, 'utf8'), initialScriptContent);
494
+ assert.equal(scriptReloadEvent.type, 'reload');
495
+
496
+ const initialStyleAsset = await waitForAssetContaining(appRoot, '.css', 'STYLE_MARKER_INITIAL');
497
+ const initialStyleContent = fs.readFileSync(initialStyleAsset, 'utf8');
498
+ const styleReloadStream = await connectToReloadStream(port + 1);
499
+
500
+ writeFile(path.join(sharedRoot, 'styles.css'), createSharedStyleSource('STYLE_MARKER_UPDATED'));
501
+
502
+ const updatedStyleAsset = await waitForAssetContaining(appRoot, '.css', 'STYLE_MARKER_UPDATED');
503
+ const styleReloadEvent = await styleReloadStream.waitForReload();
504
+ styleReloadStream.close();
505
+
506
+ assert.equal(updatedStyleAsset, initialStyleAsset);
507
+ assert.notEqual(fs.readFileSync(updatedStyleAsset, 'utf8'), initialStyleContent);
508
+ assert.equal(styleReloadEvent.type, 'reload');
509
+ } finally {
510
+ await stopDevServer(child);
511
+ fs.rmSync(root, { recursive: true, force: true });
512
+ }
513
+ });