@warp-drive/holodeck 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +1 -1
- package/declarations/index.d.ts +1 -7
- package/dist/index.js +33 -8
- package/package.json +8 -8
- package/server/bun-worker.js +3 -0
- package/server/bun.js +393 -0
- package/server/compat-shim.js +95 -0
- package/server/index.js +24 -499
- package/server/node-compat-start.js +12 -0
- package/server/node-worker.js +3 -0
- package/server/node.js +401 -0
- package/server/utils.js +128 -0
- package/server/start-node.js +0 -3
- package/server/worker.js +0 -3
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ holodeck can be launched and cleaned up using the lifecycle hooks in the launch
|
|
|
225
225
|
for diagnostic in `diagnostic.js`:
|
|
226
226
|
|
|
227
227
|
```ts
|
|
228
|
-
import launch from '@warp-drive/diagnostic/server
|
|
228
|
+
import { launch } from '@warp-drive/diagnostic/server';
|
|
229
229
|
import holodeck from '@warp-drive/holodeck';
|
|
230
230
|
|
|
231
231
|
await launch({
|
package/declarations/index.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Handler, NextFn } from "@warp-drive/core/request";
|
|
2
2
|
import type { RequestContext, StructuredDataDocument } from "@warp-drive/core/types/request";
|
|
3
|
-
import type { MinimumAdapterInterface } from "@warp-drive/legacy/compat";
|
|
4
3
|
import type { Store } from "@warp-drive/legacy/store";
|
|
5
4
|
import type { ScaffoldGenerator } from "./mock.js";
|
|
6
5
|
/**
|
|
@@ -37,10 +36,6 @@ export declare class MockServerHandler implements Handler {
|
|
|
37
36
|
constructor(owner: object);
|
|
38
37
|
request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>>;
|
|
39
38
|
}
|
|
40
|
-
interface HasAdapterForFn {
|
|
41
|
-
adapterFor(this: Store, modelName: string): MinimumAdapterInterface;
|
|
42
|
-
adapterFor(this: Store, modelName: string, _allowMissing?: true): MinimumAdapterInterface | undefined;
|
|
43
|
-
}
|
|
44
39
|
/**
|
|
45
40
|
* Creates an adapterFor function that wraps the provided adapterFor function
|
|
46
41
|
* to override the adapter's _fetchRequest method to route requests through
|
|
@@ -48,11 +43,10 @@ interface HasAdapterForFn {
|
|
|
48
43
|
*
|
|
49
44
|
* @param owner - The test context object used to retrieve the test ID.
|
|
50
45
|
*/
|
|
51
|
-
export declare function
|
|
46
|
+
export declare function installAdapterFor(owner: object, store: Store): void;
|
|
52
47
|
/**
|
|
53
48
|
* Mock a request by sending the scaffold to the mock server.
|
|
54
49
|
*
|
|
55
50
|
* @public
|
|
56
51
|
*/
|
|
57
52
|
export declare function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean): Promise<void>;
|
|
58
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { SHOULD_RECORD } from '@warp-drive/core/build-config/env';
|
|
|
5
5
|
* @mergeModuleWith <project>
|
|
6
6
|
*/
|
|
7
7
|
const TEST_IDS = new WeakMap();
|
|
8
|
-
let HOST = '
|
|
8
|
+
let HOST = '/';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @public
|
|
@@ -126,14 +126,38 @@ function setupHolodeckFetch(owner, request) {
|
|
|
126
126
|
}
|
|
127
127
|
const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${test.request[method][url]++}`;
|
|
128
128
|
request.url = url + queryForTest;
|
|
129
|
+
request.method = method;
|
|
129
130
|
request.mode = 'cors';
|
|
130
131
|
request.credentials = 'omit';
|
|
131
132
|
request.referrerPolicy = '';
|
|
133
|
+
|
|
134
|
+
// since holodeck currently runs on a separate port
|
|
135
|
+
// and we don't want to trigger cors pre-flight
|
|
136
|
+
// we convert PUT to POST to keep the request in the
|
|
137
|
+
// "simple" cors category.
|
|
138
|
+
// if (request.method === 'PUT') {
|
|
139
|
+
// request.method = 'POST';
|
|
140
|
+
// }
|
|
141
|
+
|
|
142
|
+
const headers = new Headers(request.headers);
|
|
143
|
+
if (headers.has('Content-Type')) {
|
|
144
|
+
// under the rules of simple-cors, content-type can only be
|
|
145
|
+
// one of three things, none of which are what folks typically
|
|
146
|
+
// set this to. Since holodeck always expects body to be JSON
|
|
147
|
+
// this "just works".
|
|
148
|
+
headers.set('Content-Type', 'text/plain');
|
|
149
|
+
request.headers = headers;
|
|
150
|
+
}
|
|
132
151
|
return {
|
|
133
152
|
request,
|
|
134
153
|
queryForTest
|
|
135
154
|
};
|
|
136
155
|
}
|
|
156
|
+
function upgradeStore(store) {
|
|
157
|
+
if (typeof store.adapterFor !== 'function') {
|
|
158
|
+
throw new Error('Store is not compatible with Holodeck. Missing adapterFor method.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
137
161
|
|
|
138
162
|
/**
|
|
139
163
|
* Creates an adapterFor function that wraps the provided adapterFor function
|
|
@@ -142,11 +166,11 @@ function setupHolodeckFetch(owner, request) {
|
|
|
142
166
|
*
|
|
143
167
|
* @param owner - The test context object used to retrieve the test ID.
|
|
144
168
|
*/
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
const adapter =
|
|
169
|
+
function installAdapterFor(owner, store) {
|
|
170
|
+
upgradeStore(store);
|
|
171
|
+
const fn = store.adapterFor;
|
|
172
|
+
function holodeckAdapterFor(modelName, _allowMissing) {
|
|
173
|
+
const adapter = fn.call(this, modelName, _allowMissing);
|
|
150
174
|
if (adapter) {
|
|
151
175
|
if (!adapter.hasOverriddenFetch) {
|
|
152
176
|
adapter.hasOverriddenFetch = true;
|
|
@@ -164,7 +188,8 @@ function createAdapterFor(owner, store) {
|
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
190
|
return adapter;
|
|
167
|
-
}
|
|
191
|
+
}
|
|
192
|
+
store.adapterFor = holodeckAdapterFor;
|
|
168
193
|
}
|
|
169
194
|
|
|
170
195
|
/**
|
|
@@ -209,4 +234,4 @@ async function mock(owner, generate, isRecording) {
|
|
|
209
234
|
}
|
|
210
235
|
}
|
|
211
236
|
|
|
212
|
-
export { MockServerHandler,
|
|
237
|
+
export { MockServerHandler, getIsRecording, installAdapterFor, mock, setConfig, setIsRecording, setTestId };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warp-drive/holodeck",
|
|
3
3
|
"description": "⚡️ Simple, Fast HTTP Mocking for Tests",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.17",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Chris Thoburn <runspired@users.noreply.github.com>",
|
|
7
7
|
"repository": {
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"ensure-cert": "./server/ensure-cert.js"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@warp-drive/utilities": "5.8.0-alpha.
|
|
43
|
-
"@warp-drive/legacy": "5.8.0-alpha.
|
|
44
|
-
"@warp-drive/core": "5.8.0-alpha.
|
|
42
|
+
"@warp-drive/utilities": "5.8.0-alpha.17",
|
|
43
|
+
"@warp-drive/legacy": "5.8.0-alpha.17",
|
|
44
|
+
"@warp-drive/core": "5.8.0-alpha.17"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"@warp-drive/utilities": {
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"@babel/preset-env": "^7.28.3",
|
|
58
58
|
"@babel/preset-typescript": "^7.27.1",
|
|
59
59
|
"@babel/runtime": "^7.28.3",
|
|
60
|
-
"@warp-drive/utilities": "5.8.0-alpha.
|
|
61
|
-
"@warp-drive/legacy": "5.8.0-alpha.
|
|
62
|
-
"@warp-drive/core": "5.8.0-alpha.
|
|
63
|
-
"@warp-drive/internal-config": "5.8.0-alpha.
|
|
60
|
+
"@warp-drive/utilities": "5.8.0-alpha.17",
|
|
61
|
+
"@warp-drive/legacy": "5.8.0-alpha.17",
|
|
62
|
+
"@warp-drive/core": "5.8.0-alpha.17",
|
|
63
|
+
"@warp-drive/internal-config": "5.8.0-alpha.17",
|
|
64
64
|
"vite": "^7.1.3"
|
|
65
65
|
},
|
|
66
66
|
"exports": {
|
package/server/bun.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { createSecureServer } from 'node:http2';
|
|
4
|
+
import { logger } from 'hono/logger';
|
|
5
|
+
import { HTTPException } from 'hono/http-exception';
|
|
6
|
+
import { cors } from 'hono/cors';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { Worker, threadId, parentPort } from 'node:worker_threads';
|
|
10
|
+
import {
|
|
11
|
+
compress,
|
|
12
|
+
createCloseHandler,
|
|
13
|
+
DEFAULT_PORT,
|
|
14
|
+
generateFileDir,
|
|
15
|
+
generateFilepath,
|
|
16
|
+
getCertInfo,
|
|
17
|
+
getNiceUrl,
|
|
18
|
+
} from './utils.js';
|
|
19
|
+
|
|
20
|
+
async function replayRequest(context, cacheKey) {
|
|
21
|
+
let metaJson;
|
|
22
|
+
try {
|
|
23
|
+
metaJson = JSON.parse(fs.readFileSync(`${cacheKey}.meta.json`, 'utf8'));
|
|
24
|
+
} catch (e) {
|
|
25
|
+
context.header('Content-Type', 'application/vnd.api+json');
|
|
26
|
+
context.status(400);
|
|
27
|
+
return context.body(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
errors: [
|
|
30
|
+
{
|
|
31
|
+
status: '400',
|
|
32
|
+
code: 'MOCK_NOT_FOUND',
|
|
33
|
+
title: 'Mock not found',
|
|
34
|
+
detail: `No meta was found for ${context.req.method} ${context.req.url}. The expected cacheKey was ${cacheKey}. You may need to record a mock for this request.`,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const bodyPath = `${cacheKey}.body.br`;
|
|
43
|
+
const bodyInit = metaJson.status !== 204 && metaJson.status < 500 ? fs.createReadStream(bodyPath) : '';
|
|
44
|
+
|
|
45
|
+
const headers = new Headers(metaJson.headers || {});
|
|
46
|
+
// @ts-expect-error - createReadStream is supported in node
|
|
47
|
+
const response = new Response(bodyInit, {
|
|
48
|
+
status: metaJson.status,
|
|
49
|
+
statusText: metaJson.statusText,
|
|
50
|
+
headers,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (metaJson.status > 400) {
|
|
54
|
+
throw new HTTPException(metaJson.status, { res: response, message: metaJson.statusText });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return response;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e instanceof HTTPException) {
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
context.header('Content-Type', 'application/vnd.api+json');
|
|
63
|
+
context.status(500);
|
|
64
|
+
return context.body(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
errors: [
|
|
67
|
+
{
|
|
68
|
+
status: '500',
|
|
69
|
+
code: 'MOCK_SERVER_ERROR',
|
|
70
|
+
title: 'Mock Replay Failed',
|
|
71
|
+
detail: `Failed to create the response for ${context.req.method} ${context.req.url}.\n\n\n${e.message}\n${e.stack}`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createTestHandler(projectRoot) {
|
|
80
|
+
const TestHandler = async (context) => {
|
|
81
|
+
try {
|
|
82
|
+
const { req } = context;
|
|
83
|
+
|
|
84
|
+
const testId = req.query('__xTestId');
|
|
85
|
+
const testRequestNumber = req.query('__xTestRequestNumber');
|
|
86
|
+
const niceUrl = getNiceUrl(req.url);
|
|
87
|
+
|
|
88
|
+
if (!testId) {
|
|
89
|
+
context.header('Content-Type', 'application/vnd.api+json');
|
|
90
|
+
context.status(400);
|
|
91
|
+
return context.body(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
errors: [
|
|
94
|
+
{
|
|
95
|
+
status: '400',
|
|
96
|
+
code: 'MISSING_X_TEST_ID_HEADER',
|
|
97
|
+
title: 'Request to the http mock server is missing the `X-Test-Id` header',
|
|
98
|
+
detail:
|
|
99
|
+
"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.",
|
|
100
|
+
source: { header: 'X-Test-Id' },
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!testRequestNumber) {
|
|
108
|
+
context.header('Content-Type', 'application/vnd.api+json');
|
|
109
|
+
context.status(400);
|
|
110
|
+
return context.body(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
errors: [
|
|
113
|
+
{
|
|
114
|
+
status: '400',
|
|
115
|
+
code: 'MISSING_X_TEST_REQUEST_NUMBER_HEADER',
|
|
116
|
+
title: 'Request to the http mock server is missing the `X-Test-Request-Number` header',
|
|
117
|
+
detail:
|
|
118
|
+
"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.",
|
|
119
|
+
source: { header: 'X-Test-Request-Number' },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === 'POST' && niceUrl === '__record') {
|
|
127
|
+
const payload = await req.json();
|
|
128
|
+
const { url, headers, method, status, statusText, body, response } = payload;
|
|
129
|
+
const cacheKey = generateFilepath({
|
|
130
|
+
projectRoot,
|
|
131
|
+
testId,
|
|
132
|
+
url,
|
|
133
|
+
method,
|
|
134
|
+
body,
|
|
135
|
+
testRequestNumber,
|
|
136
|
+
});
|
|
137
|
+
const compressedResponse = compress(JSON.stringify(response));
|
|
138
|
+
// allow Content-Type to be overridden
|
|
139
|
+
headers['Content-Type'] = headers['Content-Type'] || 'application/vnd.api+json';
|
|
140
|
+
// We always compress and chunk the response
|
|
141
|
+
headers['Content-Encoding'] = 'br';
|
|
142
|
+
// we don't cache since tests will often reuse similar urls for different payload
|
|
143
|
+
headers['Cache-Control'] = 'no-store';
|
|
144
|
+
// streaming requires Content-Length
|
|
145
|
+
headers['Content-Length'] = compressedResponse.length;
|
|
146
|
+
|
|
147
|
+
const cacheDir = generateFileDir({
|
|
148
|
+
projectRoot,
|
|
149
|
+
testId,
|
|
150
|
+
url,
|
|
151
|
+
method,
|
|
152
|
+
testRequestNumber,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
`${cacheKey}.meta.json`,
|
|
159
|
+
JSON.stringify({ url, status, statusText, headers, method, requestBody: body })
|
|
160
|
+
);
|
|
161
|
+
fs.writeFileSync(`${cacheKey}.body.br`, compressedResponse);
|
|
162
|
+
|
|
163
|
+
context.status(201);
|
|
164
|
+
return context.body(
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
message: `Recorded ${method} ${url} for test ${testId} request #${testRequestNumber}`,
|
|
167
|
+
cacheKey,
|
|
168
|
+
cacheDir,
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
const body = req.raw.body ? await req.text() : null;
|
|
173
|
+
const cacheKey = generateFilepath({
|
|
174
|
+
projectRoot,
|
|
175
|
+
testId,
|
|
176
|
+
url: niceUrl,
|
|
177
|
+
method: req.method,
|
|
178
|
+
body: body ? body : null,
|
|
179
|
+
testRequestNumber,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// console.log(
|
|
183
|
+
// `Replaying mock for ${req.method} ${niceUrl} (test: ${testId} request #${testRequestNumber}) from '${cacheKey}' if available`
|
|
184
|
+
// );
|
|
185
|
+
return replayRequest(context, cacheKey);
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e instanceof HTTPException) {
|
|
189
|
+
console.log(`HTTPException Encountered`);
|
|
190
|
+
console.error(e);
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
console.log(`500 MOCK_SERVER_ERROR Encountered`);
|
|
194
|
+
console.error(e);
|
|
195
|
+
context.header('Content-Type', 'application/vnd.api+json');
|
|
196
|
+
context.status(500);
|
|
197
|
+
return context.body(
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
errors: [
|
|
200
|
+
{
|
|
201
|
+
status: '500',
|
|
202
|
+
code: 'MOCK_SERVER_ERROR',
|
|
203
|
+
title: 'Mock Server Error during Request',
|
|
204
|
+
detail: e.message,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return TestHandler;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function startWorker() {
|
|
216
|
+
let close;
|
|
217
|
+
parentPort.postMessage('ready');
|
|
218
|
+
// listen for launch message
|
|
219
|
+
parentPort.on('message', async (event) => {
|
|
220
|
+
// console.log('worker message received', event);
|
|
221
|
+
if (typeof event === 'object' && event?.type === 'launch') {
|
|
222
|
+
// console.log('worker launching');
|
|
223
|
+
const { options } = event;
|
|
224
|
+
const result = await _createServer(options);
|
|
225
|
+
parentPort.postMessage({
|
|
226
|
+
type: 'launched',
|
|
227
|
+
protocol: 'https',
|
|
228
|
+
hostname: result.location.hostname,
|
|
229
|
+
port: result.location.port,
|
|
230
|
+
});
|
|
231
|
+
close = result.close;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (event === 'end') {
|
|
235
|
+
// console.log('worker shutting down');
|
|
236
|
+
close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
{ port?: number, projectRoot: string }
|
|
243
|
+
*/
|
|
244
|
+
async function createServer(options) {
|
|
245
|
+
if (options.useWorker) {
|
|
246
|
+
// console.log('starting holodeck worker');
|
|
247
|
+
const worker = new Worker(new URL('./bun-worker.js', import.meta.url));
|
|
248
|
+
|
|
249
|
+
const started = new Promise((resolve) => {
|
|
250
|
+
worker.on('message', (v) => {
|
|
251
|
+
// console.log('worker message received', v);
|
|
252
|
+
if (v === 'ready') {
|
|
253
|
+
worker.postMessage({
|
|
254
|
+
type: 'launch',
|
|
255
|
+
options,
|
|
256
|
+
});
|
|
257
|
+
} else if (v.type === 'launched') {
|
|
258
|
+
// @ts-expect-error
|
|
259
|
+
worker.location = v;
|
|
260
|
+
resolve(worker);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await started;
|
|
266
|
+
console.log('\tworker booted');
|
|
267
|
+
return {
|
|
268
|
+
worker,
|
|
269
|
+
server: {
|
|
270
|
+
close() {
|
|
271
|
+
worker.postMessage('end');
|
|
272
|
+
worker.terminate();
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
// @ts-expect-error
|
|
276
|
+
location: worker.location,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return _createServer(options);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function _createServer(options) {
|
|
284
|
+
const { CERT, KEY } = await getCertInfo();
|
|
285
|
+
const app = new Hono();
|
|
286
|
+
// app.use(logger());
|
|
287
|
+
|
|
288
|
+
app.use(
|
|
289
|
+
cors({
|
|
290
|
+
origin: (origin, context) => {
|
|
291
|
+
// console.log(context.req.raw.headers);
|
|
292
|
+
const result = origin.startsWith('http://localhost:') || origin.startsWith('https://localhost:') ? origin : '*';
|
|
293
|
+
// console.log(`CORS Origin: ${origin} => ${result}`);
|
|
294
|
+
return result;
|
|
295
|
+
},
|
|
296
|
+
allowHeaders: ['Accept', 'Content-Type'],
|
|
297
|
+
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'],
|
|
298
|
+
exposeHeaders: ['Content-Length', 'Content-Type'],
|
|
299
|
+
maxAge: 60_000,
|
|
300
|
+
credentials: false,
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
app.all('*', createTestHandler(options.projectRoot));
|
|
304
|
+
|
|
305
|
+
const location = {
|
|
306
|
+
port: options.port ?? DEFAULT_PORT,
|
|
307
|
+
hostname: options.hostname ?? 'localhost',
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const server = Bun.serve({
|
|
311
|
+
fetch: app.fetch,
|
|
312
|
+
tls: {
|
|
313
|
+
key: KEY,
|
|
314
|
+
cert: CERT,
|
|
315
|
+
},
|
|
316
|
+
port: location.port,
|
|
317
|
+
hostname: location.hostname,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
console.log(
|
|
321
|
+
`\tServing Holodeck HTTP Mocks from ${chalk.yellow('https://') + chalk.magenta(location.hostname + ':') + chalk.yellow(location.port)}\n`
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (typeof threadId === 'number' && threadId !== 0) {
|
|
325
|
+
parentPort.postMessage({
|
|
326
|
+
type: 'launched',
|
|
327
|
+
protocol: 'https',
|
|
328
|
+
hostname: location.hostname,
|
|
329
|
+
port: location.port,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (typeof threadId === 'number' && threadId !== 0) {
|
|
334
|
+
const close = createCloseHandler(() => {
|
|
335
|
+
return server.stop();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return { app, server, location, close };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
app,
|
|
343
|
+
server,
|
|
344
|
+
location,
|
|
345
|
+
close: () => {
|
|
346
|
+
return server.stop();
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function launchProgram(config = {}) {
|
|
352
|
+
const projectRoot = process.cwd();
|
|
353
|
+
const pkg = await import(path.join(projectRoot, 'package.json'), { with: { type: 'json' } });
|
|
354
|
+
const { name } = pkg.default ?? pkg;
|
|
355
|
+
if (!name) {
|
|
356
|
+
throw new Error(`Package name not found in package.json`);
|
|
357
|
+
}
|
|
358
|
+
const options = { name, projectRoot, ...config };
|
|
359
|
+
console.log(
|
|
360
|
+
chalk.grey(
|
|
361
|
+
`\n\t@${chalk.greenBright('warp-drive')}/${chalk.magentaBright(
|
|
362
|
+
'holodeck'
|
|
363
|
+
)} 🌅\n\t=================================\n`
|
|
364
|
+
) +
|
|
365
|
+
chalk.grey(
|
|
366
|
+
`\n\tHolodeck Access Granted\n\t\tprogram: ${chalk.magenta(name)}\n\t\tsettings: ${chalk.green(
|
|
367
|
+
JSON.stringify(config).split('\n').join(' ')
|
|
368
|
+
)}\n\t\tdirectory: ${chalk.cyan(projectRoot)}\n\t\tengine: ${chalk.cyan('bun')}@${chalk.yellow(Bun.version)}\n`
|
|
369
|
+
)
|
|
370
|
+
);
|
|
371
|
+
console.log(chalk.grey(`\n\tStarting Holodeck Subroutines`));
|
|
372
|
+
|
|
373
|
+
const project = await createServer(options);
|
|
374
|
+
|
|
375
|
+
async function shutdown() {
|
|
376
|
+
console.log(chalk.grey(`\n\tEnding Holodeck Subroutines`));
|
|
377
|
+
project.close();
|
|
378
|
+
console.log(chalk.grey(`\n\tHolodeck program ended`));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const endProgram = createCloseHandler(shutdown);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
config: {
|
|
385
|
+
location: `https://${project.location.hostname}:${project.location.port}`,
|
|
386
|
+
port: project.location.port,
|
|
387
|
+
hostname: project.location.hostname,
|
|
388
|
+
protocol: 'https',
|
|
389
|
+
recordingPath: `/__record`,
|
|
390
|
+
},
|
|
391
|
+
endProgram,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { stripVTControlCharacters } from 'util';
|
|
3
|
+
const HOST_LOG_MESSAGE = `Serving Holodeck HTTP Mocks from`;
|
|
4
|
+
const WORKER_DONE_LOG_MESSAGE = `worker booted`;
|
|
5
|
+
const HTTPS_EXTRACT_MATCH = /https:\/\/([^:]+):(\d+)/;
|
|
6
|
+
|
|
7
|
+
async function waitForBoot(server, useWorker = false) {
|
|
8
|
+
let port = null;
|
|
9
|
+
let hostname = null;
|
|
10
|
+
let done = false;
|
|
11
|
+
|
|
12
|
+
for await (const chunk of server.stdout) {
|
|
13
|
+
process.stdout.write(chunk);
|
|
14
|
+
const txt = new TextDecoder().decode(chunk);
|
|
15
|
+
if (txt.includes(HOST_LOG_MESSAGE)) {
|
|
16
|
+
const urlMatch = HTTPS_EXTRACT_MATCH.exec(stripVTControlCharacters(txt));
|
|
17
|
+
if (urlMatch) {
|
|
18
|
+
hostname = urlMatch[1];
|
|
19
|
+
port = parseInt(urlMatch[2], 10);
|
|
20
|
+
}
|
|
21
|
+
if (!useWorker) {
|
|
22
|
+
done = true;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (useWorker && txt.includes(WORKER_DONE_LOG_MESSAGE)) {
|
|
27
|
+
done = true;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!done) {
|
|
33
|
+
throw new Error('Holodeck server failed to start');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!port || !hostname) {
|
|
37
|
+
throw new Error('Could not determine Holodeck server port');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { port, hostname };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function reprintLogs(server) {
|
|
44
|
+
for await (const chunk of server.stdout) {
|
|
45
|
+
process.stdout.write(chunk);
|
|
46
|
+
|
|
47
|
+
if (server.killed) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function reprintErrors(server) {
|
|
54
|
+
for await (const chunk of server.stderr) {
|
|
55
|
+
process.stderr.write(chunk);
|
|
56
|
+
|
|
57
|
+
if (server.killed) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* If we are launched with bun but still want to use node,
|
|
65
|
+
* this will spawn a child process to run the node server.
|
|
66
|
+
*
|
|
67
|
+
*/
|
|
68
|
+
export async function launchProgram(config = {}) {
|
|
69
|
+
const CURRENT_FILE = new URL(import.meta.url).pathname;
|
|
70
|
+
const START_FILE = path.join(CURRENT_FILE, '../node-compat-start.js');
|
|
71
|
+
const server = Bun.spawn(['node', START_FILE, JSON.stringify(config)], {
|
|
72
|
+
env: Object.assign({}, process.env, { FORCE_COLOR: 1 }),
|
|
73
|
+
cwd: process.cwd(),
|
|
74
|
+
stdin: 'ignore',
|
|
75
|
+
stdout: 'pipe',
|
|
76
|
+
stderr: 'pipe',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { hostname, port } = await waitForBoot(server, config.useWorker);
|
|
80
|
+
void reprintLogs(server);
|
|
81
|
+
void reprintErrors(server);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
config: {
|
|
85
|
+
location: `https://${hostname}:${port}`,
|
|
86
|
+
port,
|
|
87
|
+
hostname,
|
|
88
|
+
protocol: 'https',
|
|
89
|
+
recordingPath: `/__record`,
|
|
90
|
+
},
|
|
91
|
+
endProgram: () => {
|
|
92
|
+
server.kill();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|