@warp-drive/holodeck 0.0.0-beta.9 → 0.0.1-beta.0

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/server/index.js CHANGED
@@ -1,66 +1,73 @@
1
1
  /* global Bun */
2
- import { serve } from '@hono/node-server';
3
2
  import chalk from 'chalk';
4
3
  import { Hono } from 'hono';
5
- import { cors } from 'hono/cors';
6
- import { HTTPException } from 'hono/http-exception';
4
+ import { serve } from '@hono/node-server';
5
+ import { createSecureServer } from 'node:http2';
7
6
  import { logger } from 'hono/logger';
8
- import { execSync } from 'node:child_process';
7
+ import { HTTPException } from 'hono/http-exception';
8
+ import { cors } from 'hono/cors';
9
9
  import crypto from 'node:crypto';
10
10
  import fs from 'node:fs';
11
- import http2 from 'node:http2';
12
11
  import zlib from 'node:zlib';
13
- import { homedir, userInfo } from 'os';
12
+ import { homedir } from 'os';
14
13
  import path from 'path';
15
14
 
16
- /** @type {import('bun-types')} */
17
15
  const isBun = typeof Bun !== 'undefined';
18
- const DEBUG = process.env.DEBUG?.includes('holodeck') || process.env.DEBUG === '*';
19
- const CURRENT_FILE = new URL(import.meta.url).pathname;
20
-
21
- function getShellConfigFilePath() {
22
- const shell = userInfo().shell;
23
- switch (shell) {
24
- case '/bin/zsh':
25
- return path.join(homedir(), '.zshrc');
26
- case '/bin/bash':
27
- return path.join(homedir(), '.bashrc');
28
- default:
29
- throw Error(
30
- `Unable to determine configuration file for shell: ${shell}. Manual SSL Cert Setup Required for Holodeck.`
31
- );
32
- }
33
- }
16
+ const DEBUG =
17
+ process.env.DEBUG?.includes('wd:holodeck') || process.env.DEBUG === '*' || process.env.DEBUG?.includes('wd:*');
34
18
 
35
- function getCertInfo() {
19
+ async function getCertInfo() {
36
20
  let CERT_PATH = process.env.HOLODECK_SSL_CERT_PATH;
37
21
  let KEY_PATH = process.env.HOLODECK_SSL_KEY_PATH;
38
- const configFilePath = getShellConfigFilePath();
39
22
 
40
23
  if (!CERT_PATH) {
41
24
  CERT_PATH = path.join(homedir(), 'holodeck-localhost.pem');
42
25
  process.env.HOLODECK_SSL_CERT_PATH = CERT_PATH;
43
- execSync(`echo '\nexport HOLODECK_SSL_CERT_PATH="${CERT_PATH}"' >> ${configFilePath}`);
44
- console.log(`Added HOLODECK_SSL_CERT_PATH to ${configFilePath}`);
26
+
27
+ console.log(
28
+ `HOLODECK_SSL_CERT_PATH was not found in the current environment. Setting it to default value of ${CERT_PATH}`
29
+ );
45
30
  }
46
31
 
47
32
  if (!KEY_PATH) {
48
33
  KEY_PATH = path.join(homedir(), 'holodeck-localhost-key.pem');
49
34
  process.env.HOLODECK_SSL_KEY_PATH = KEY_PATH;
50
- execSync(`echo '\nexport HOLODECK_SSL_KEY_PATH="${KEY_PATH}"' >> ${configFilePath}`);
51
- console.log(`Added HOLODECK_SSL_KEY_PATH to ${configFilePath}`);
52
- }
53
35
 
54
- if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) {
55
- throw new Error('SSL certificate or key not found, you may need to run `npx -p @warp-drive/holodeck ensure-cert`');
36
+ console.log(
37
+ `HOLODECK_SSL_KEY_PATH was not found in the current environment. Setting it to default value of ${KEY_PATH}`
38
+ );
56
39
  }
57
40
 
58
- return {
59
- CERT_PATH,
60
- KEY_PATH,
61
- CERT: fs.readFileSync(CERT_PATH),
62
- KEY: fs.readFileSync(KEY_PATH),
63
- };
41
+ if (isBun) {
42
+ const CERT = Bun.file(CERT_PATH);
43
+ const KEY = Bun.file(KEY_PATH);
44
+
45
+ if (!(await CERT.exists()) || !(await KEY.exists())) {
46
+ throw new Error(
47
+ 'SSL certificate or key not found, you may need to run `pnpm dlx @warp-drive/holodeck ensure-cert`'
48
+ );
49
+ }
50
+
51
+ return {
52
+ CERT_PATH,
53
+ KEY_PATH,
54
+ CERT: await CERT.text(),
55
+ KEY: await KEY.text(),
56
+ };
57
+ } else {
58
+ if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) {
59
+ throw new Error(
60
+ 'SSL certificate or key not found, you may need to run `pnpm dlx @warp-drive/holodeck ensure-cert`'
61
+ );
62
+ }
63
+
64
+ return {
65
+ CERT_PATH,
66
+ KEY_PATH,
67
+ CERT: fs.readFileSync(CERT_PATH, 'utf8'),
68
+ KEY: fs.readFileSync(KEY_PATH, 'utf8'),
69
+ };
70
+ }
64
71
  }
65
72
 
66
73
  const DEFAULT_PORT = 1135;
@@ -97,7 +104,7 @@ function getNiceUrl(url) {
97
104
  */
98
105
  function generateFilepath(options) {
99
106
  const { body } = options;
100
- const bodyHash = body ? crypto.createHash('md5').update(body).digest('hex') : null;
107
+ const bodyHash = body ? crypto.createHash('md5').update(JSON.stringify(body)).digest('hex') : null;
101
108
  const cacheDir = generateFileDir(options);
102
109
  return `${cacheDir}/${bodyHash ? `${bodyHash}-` : 'res'}`;
103
110
  }
@@ -106,10 +113,14 @@ function generateFileDir(options) {
106
113
  return `${projectRoot}/.mock-cache/${testId}/${method}-${testRequestNumber}-${url}`;
107
114
  }
108
115
 
109
- function replayRequest(context, cacheKey) {
110
- let meta;
116
+ async function replayRequest(context, cacheKey) {
117
+ let metaJson;
111
118
  try {
112
- meta = fs.readFileSync(`${cacheKey}.meta.json`, 'utf-8');
119
+ if (isBun) {
120
+ metaJson = await Bun.file(`${cacheKey}.meta.json`).json();
121
+ } else {
122
+ metaJson = JSON.parse(fs.readFileSync(`${cacheKey}.meta.json`, 'utf8'));
123
+ }
113
124
  } catch (e) {
114
125
  context.header('Content-Type', 'application/vnd.api+json');
115
126
  context.status(400);
@@ -120,18 +131,23 @@ function replayRequest(context, cacheKey) {
120
131
  status: '400',
121
132
  code: 'MOCK_NOT_FOUND',
122
133
  title: 'Mock not found',
123
- detail: `No mock found for ${context.req.method} ${context.req.url}. You may need to record a mock for this request.`,
134
+ detail: `No meta was found for ${context.req.method} ${context.req.url}. You may need to record a mock for this request.`,
124
135
  },
125
136
  ],
126
137
  })
127
138
  );
128
139
  }
129
140
 
130
- const metaJson = JSON.parse(meta);
131
141
  const bodyPath = `${cacheKey}.body.br`;
142
+ const bodyInit =
143
+ metaJson.status !== 204 && metaJson.status < 500
144
+ ? isBun
145
+ ? Bun.file(bodyPath)
146
+ : fs.createReadStream(bodyPath)
147
+ : '';
132
148
 
133
149
  const headers = new Headers(metaJson.headers || {});
134
- const bodyInit = metaJson.status !== 204 && metaJson.status < 500 ? fs.createReadStream(bodyPath) : '';
150
+ // @ts-expect-error - createReadStream is supported in node
135
151
  const response = new Response(bodyInit, {
136
152
  status: metaJson.status,
137
153
  statusText: metaJson.statusText,
@@ -147,105 +163,195 @@ function replayRequest(context, cacheKey) {
147
163
 
148
164
  function createTestHandler(projectRoot) {
149
165
  const TestHandler = async (context) => {
150
- const { req } = context;
166
+ try {
167
+ const { req } = context;
168
+
169
+ const testId = req.query('__xTestId');
170
+ const testRequestNumber = req.query('__xTestRequestNumber');
171
+ const niceUrl = getNiceUrl(req.url);
172
+
173
+ if (!testId) {
174
+ context.header('Content-Type', 'application/vnd.api+json');
175
+ context.status(400);
176
+ return context.body(
177
+ JSON.stringify({
178
+ errors: [
179
+ {
180
+ status: '400',
181
+ code: 'MISSING_X_TEST_ID_HEADER',
182
+ title: 'Request to the http mock server is missing the `X-Test-Id` header',
183
+ detail:
184
+ "The `X-Test-Id` header is used to identify the test that is making the request to the mock server. This is used to ensure that the mock server is only used for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
185
+ source: { header: 'X-Test-Id' },
186
+ },
187
+ ],
188
+ })
189
+ );
190
+ }
151
191
 
152
- const testId = req.query('__xTestId');
153
- const testRequestNumber = req.query('__xTestRequestNumber');
154
- const niceUrl = getNiceUrl(req.url);
192
+ if (!testRequestNumber) {
193
+ context.header('Content-Type', 'application/vnd.api+json');
194
+ context.status(400);
195
+ return context.body(
196
+ JSON.stringify({
197
+ errors: [
198
+ {
199
+ status: '400',
200
+ code: 'MISSING_X_TEST_REQUEST_NUMBER_HEADER',
201
+ title: 'Request to the http mock server is missing the `X-Test-Request-Number` header',
202
+ detail:
203
+ "The `X-Test-Request-Number` header is used to identify the request number for the current test. This is used to ensure that the mock server response is deterministic for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
204
+ source: { header: 'X-Test-Request-Number' },
205
+ },
206
+ ],
207
+ })
208
+ );
209
+ }
155
210
 
156
- if (!testId) {
157
- context.header('Content-Type', 'application/vnd.api+json');
158
- context.status(400);
159
- return context.body(
160
- JSON.stringify({
161
- errors: [
162
- {
163
- status: '400',
164
- code: 'MISSING_X_TEST_ID_HEADER',
165
- title: 'Request to the http mock server is missing the `X-Test-Id` header',
166
- detail:
167
- "The `X-Test-Id` header is used to identify the test that is making the request to the mock server. This is used to ensure that the mock server is only used for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
168
- source: { header: 'X-Test-Id' },
169
- },
170
- ],
171
- })
172
- );
173
- }
211
+ if (req.method === 'POST' || niceUrl === '__record') {
212
+ const payload = await req.json();
213
+ const { url, headers, method, status, statusText, body, response } = payload;
214
+ const cacheKey = generateFilepath({
215
+ projectRoot,
216
+ testId,
217
+ url,
218
+ method,
219
+ body: body ? JSON.stringify(body) : null,
220
+ testRequestNumber,
221
+ });
222
+ const compressedResponse = compress(JSON.stringify(response));
223
+ // allow Content-Type to be overridden
224
+ headers['Content-Type'] = headers['Content-Type'] || 'application/vnd.api+json';
225
+ // We always compress and chunk the response
226
+ headers['Content-Encoding'] = 'br';
227
+ // we don't cache since tests will often reuse similar urls for different payload
228
+ headers['Cache-Control'] = 'no-store';
229
+ // streaming requires Content-Length
230
+ headers['Content-Length'] = compressedResponse.length;
231
+
232
+ const cacheDir = generateFileDir({
233
+ projectRoot,
234
+ testId,
235
+ url,
236
+ method,
237
+ testRequestNumber,
238
+ });
239
+
240
+ fs.mkdirSync(cacheDir, { recursive: true });
241
+
242
+ if (isBun) {
243
+ const newMetaFile = Bun.file(`${cacheKey}.meta.json`);
244
+ await newMetaFile.write(JSON.stringify({ url, status, statusText, headers, method, requestBody: body }));
245
+ const newBodyFile = Bun.file(`${cacheKey}.body.br`);
246
+ await newBodyFile.write(compressedResponse);
247
+ } else {
248
+ fs.writeFileSync(
249
+ `${cacheKey}.meta.json`,
250
+ JSON.stringify({ url, status, statusText, headers, method, requestBody: body })
251
+ );
252
+ fs.writeFileSync(`${cacheKey}.body.br`, compressedResponse);
253
+ }
174
254
 
175
- if (!testRequestNumber) {
255
+ context.status(204);
256
+ return context.body(null);
257
+ } else {
258
+ const body = req.body;
259
+ const cacheKey = generateFilepath({
260
+ projectRoot,
261
+ testId,
262
+ url: niceUrl,
263
+ method: req.method,
264
+ body: body ? JSON.stringify(body) : null,
265
+ testRequestNumber,
266
+ });
267
+ return replayRequest(context, cacheKey);
268
+ }
269
+ } catch (e) {
270
+ if (e instanceof HTTPException) {
271
+ throw e;
272
+ }
176
273
  context.header('Content-Type', 'application/vnd.api+json');
177
- context.status(400);
274
+ context.status(500);
178
275
  return context.body(
179
276
  JSON.stringify({
180
277
  errors: [
181
278
  {
182
- status: '400',
183
- code: 'MISSING_X_TEST_REQUEST_NUMBER_HEADER',
184
- title: 'Request to the http mock server is missing the `X-Test-Request-Number` header',
185
- detail:
186
- "The `X-Test-Request-Number` header is used to identify the request number for the current test. This is used to ensure that the mock server response is deterministic for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
187
- source: { header: 'X-Test-Request-Number' },
279
+ status: '500',
280
+ code: 'MOCK_SERVER_ERROR',
281
+ title: 'Mock Server Error during Request',
282
+ detail: e.message,
188
283
  },
189
284
  ],
190
285
  })
191
286
  );
192
287
  }
193
-
194
- if (req.method === 'POST' || niceUrl === '__record') {
195
- const payload = await req.json();
196
- const { url, headers, method, status, statusText, body, response } = payload;
197
- const cacheKey = generateFilepath({
198
- projectRoot,
199
- testId,
200
- url,
201
- method,
202
- body: body ? JSON.stringify(body) : null,
203
- testRequestNumber,
204
- });
205
- // allow Content-Type to be overridden
206
- headers['Content-Type'] = headers['Content-Type'] || 'application/vnd.api+json';
207
- // We always compress and chunk the response
208
- headers['Content-Encoding'] = 'br';
209
- // we don't cache since tests will often reuse similar urls for different payload
210
- headers['Cache-Control'] = 'no-store';
211
-
212
- const cacheDir = generateFileDir({
213
- projectRoot,
214
- testId,
215
- url,
216
- method,
217
- testRequestNumber,
218
- });
219
-
220
- fs.mkdirSync(cacheDir, { recursive: true });
221
- fs.writeFileSync(
222
- `${cacheKey}.meta.json`,
223
- JSON.stringify({ url, status, statusText, headers, method, requestBody: body }, null, 2)
224
- );
225
- fs.writeFileSync(`${cacheKey}.body.br`, compress(JSON.stringify(response)));
226
- context.status(204);
227
- return context.body(null);
228
- } else {
229
- const body = await req.text();
230
- const cacheKey = generateFilepath({
231
- projectRoot,
232
- testId,
233
- url: niceUrl,
234
- method: req.method,
235
- body,
236
- testRequestNumber,
237
- });
238
- return replayRequest(context, cacheKey);
239
- }
240
288
  };
241
289
 
242
290
  return TestHandler;
243
291
  }
244
292
 
293
+ export function startNodeServer() {
294
+ const args = process.argv.slice();
295
+
296
+ if (!isBun && args.length) {
297
+ const options = JSON.parse(args[2]);
298
+ _createServer(options);
299
+ }
300
+ }
301
+
302
+ export function startWorker() {
303
+ // listen for launch message
304
+ globalThis.onmessage = async (event) => {
305
+ const { options } = event.data;
306
+
307
+ const { server } = await _createServer(options);
308
+
309
+ // listen for messages
310
+ globalThis.onmessage = (event) => {
311
+ const message = event.data;
312
+ if (message === 'end') {
313
+ server.close();
314
+ globalThis.close();
315
+ }
316
+ };
317
+ };
318
+ }
319
+
245
320
  /*
246
321
  { port?: number, projectRoot: string }
247
322
  */
248
- export function createServer(options) {
323
+ export async function createServer(options, useBun = false) {
324
+ if (!useBun) {
325
+ const CURRENT_FILE = new URL(import.meta.url).pathname;
326
+ const START_FILE = path.join(CURRENT_FILE, '../start-node.js');
327
+ const server = Bun.spawn(['node', '--experimental-default-type=module', START_FILE, JSON.stringify(options)], {
328
+ env: process.env,
329
+ cwd: process.cwd(),
330
+ stdin: 'inherit',
331
+ stdout: 'inherit',
332
+ stderr: 'inherit',
333
+ });
334
+
335
+ return {
336
+ terminate() {
337
+ server.kill();
338
+ // server.unref();
339
+ },
340
+ };
341
+ }
342
+
343
+ const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
344
+
345
+ worker.postMessage({
346
+ type: 'launch',
347
+ options,
348
+ });
349
+
350
+ return worker;
351
+ }
352
+
353
+ async function _createServer(options) {
354
+ const { CERT, KEY } = await getCertInfo();
249
355
  const app = new Hono();
250
356
  if (DEBUG) {
251
357
  app.use('*', logger());
@@ -264,38 +370,26 @@ export function createServer(options) {
264
370
  );
265
371
  app.all('*', createTestHandler(options.projectRoot));
266
372
 
267
- const { CERT, KEY } = getCertInfo();
268
-
269
- serve({
373
+ const server = serve({
374
+ overrideGlobalObjects: !isBun,
270
375
  fetch: app.fetch,
271
- createServer: (_, requestListener) => {
272
- try {
273
- return http2.createSecureServer(
274
- {
275
- key: KEY,
276
- cert: CERT,
277
- },
278
- requestListener
279
- );
280
- } catch (e) {
281
- console.log(chalk.yellow(`Failed to create secure server, falling back to http server. Error: ${e.message}`));
282
- return http2.createServer(requestListener);
283
- }
376
+ serverOptions: {
377
+ key: KEY,
378
+ cert: CERT,
284
379
  },
380
+ createServer: createSecureServer,
285
381
  port: options.port ?? DEFAULT_PORT,
286
- hostname: 'localhost',
287
- // bun uses TLS options
288
- // tls: {
289
- // key: Bun.file(KEY_PATH),
290
- // cert: Bun.file(CERT_PATH),
291
- // },
382
+ hostname: options.hostname ?? 'localhost',
292
383
  });
293
384
 
294
385
  console.log(
295
- `\tMock server running at ${chalk.magenta('https://localhost:') + chalk.yellow(options.port ?? DEFAULT_PORT)}`
386
+ `\tMock server running at ${chalk.yellow('https://') + chalk.magenta((options.hostname ?? 'localhost') + ':') + chalk.yellow(options.port ?? DEFAULT_PORT)}`
296
387
  );
388
+
389
+ return { app, server };
297
390
  }
298
391
 
392
+ /** @type {Map<string, Awaited<ReturnType<typeof createServer>>>} */
299
393
  const servers = new Map();
300
394
 
301
395
  export default {
@@ -317,59 +411,29 @@ export default {
317
411
  )}`
318
412
  )
319
413
  );
320
- console.log(chalk.grey(`\n\tStarting Subroutines (mode:${chalk.cyan(isBun ? 'bun' : 'node')})`));
321
-
322
- if (isBun) {
323
- const serverProcess = Bun.spawn(
324
- ['node', '--experimental-default-type=module', CURRENT_FILE, JSON.stringify(options)],
325
- {
326
- env: process.env,
327
- cwd: process.cwd(),
328
- stdout: 'inherit',
329
- stderr: 'inherit',
330
- }
331
- );
332
- servers.set(projectRoot, serverProcess);
333
- return;
334
- }
414
+ console.log(chalk.grey(`\n\tStarting Holodeck Subroutines (mode:${chalk.cyan(isBun ? 'bun' : 'node')})`));
335
415
 
336
416
  if (servers.has(projectRoot)) {
337
417
  throw new Error(`Holodeck is already running for project '${name}' at '${projectRoot}'`);
338
418
  }
339
419
 
340
- servers.set(projectRoot, createServer(options));
420
+ // toggle to true if Bun fixes CORS support for HTTP/2
421
+ const project = await createServer(options, false);
422
+ servers.set(projectRoot, project);
341
423
  },
342
424
  async endProgram() {
343
- console.log(chalk.grey(`\n\tEnding Subroutines (mode:${chalk.cyan(isBun ? 'bun' : 'node')})`));
425
+ console.log(chalk.grey(`\n\tEnding Holodeck Subroutines (mode:${chalk.cyan(isBun ? 'bun' : 'node')})`));
344
426
  const projectRoot = process.cwd();
345
- const name = await import(path.join(projectRoot, 'package.json'), { with: { type: 'json' } }).then(
346
- (pkg) => pkg.name
347
- );
348
427
 
349
428
  if (!servers.has(projectRoot)) {
350
- throw new Error(`Holodeck was not running for project '${name}' at '${projectRoot}'`);
351
- }
352
-
353
- if (isBun) {
354
- const serverProcess = servers.get(projectRoot);
355
- serverProcess.kill();
429
+ const name = require(path.join(projectRoot, 'package.json')).name;
430
+ console.log(chalk.red(`\n\nHolodeck was not running for project '${name}' at '${projectRoot}'\n\n`));
356
431
  return;
357
432
  }
358
433
 
359
- servers.get(projectRoot).close();
434
+ const project = servers.get(projectRoot);
360
435
  servers.delete(projectRoot);
436
+ project.terminate();
437
+ console.log(chalk.grey(`\n\tHolodeck program ended`));
361
438
  },
362
439
  };
363
-
364
- function main() {
365
- const args = process.argv.slice();
366
- if (!isBun && args.length) {
367
- if (args[1] !== CURRENT_FILE) {
368
- return;
369
- }
370
- const options = JSON.parse(args[2]);
371
- createServer(options);
372
- }
373
- }
374
-
375
- main();
@@ -0,0 +1,3 @@
1
+ import { startNodeServer } from './index.js';
2
+
3
+ startNodeServer();
@@ -0,0 +1,12 @@
1
+ {
2
+ "include": ["."],
3
+ "compilerOptions": {
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "types": ["bun-types"],
8
+ "allowJs": true,
9
+ "checkJs": true,
10
+ "noEmit": true
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ import { startWorker } from './index.js';
2
+
3
+ startWorker();
File without changes