@warp-drive/holodeck 0.1.0-beta.0 → 0.1.0-beta.2

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/node.js CHANGED
@@ -1,14 +1,16 @@
1
- import chalk from 'chalk';
2
- import { Hono } from 'hono';
3
1
  import { serve } from '@hono/node-server';
4
- import { createSecureServer } from 'node:http2';
5
- import { logger } from 'hono/logger';
6
- import { HTTPException } from 'hono/http-exception';
2
+ import { Hono } from 'hono';
7
3
  import { cors } from 'hono/cors';
4
+ import { HTTPException } from 'hono/http-exception';
5
+ import { logger } from 'hono/logger';
8
6
  import fs from 'node:fs';
9
- import path from 'path';
7
+ import { createSecureServer } from 'node:http2';
8
+ import { styleText } from 'node:util';
10
9
  import { Worker, threadId, parentPort } from 'node:worker_threads';
10
+ import path from 'path';
11
+
11
12
  import {
13
+ bindWithRetry,
12
14
  compress,
13
15
  createCloseHandler,
14
16
  DEFAULT_PORT,
@@ -308,26 +310,28 @@ async function _createServer(options) {
308
310
  hostname: options.hostname ?? 'localhost',
309
311
  };
310
312
 
311
- const server = serve({
312
- overrideGlobalObjects: true,
313
- fetch: app.fetch,
314
- serverOptions: {
315
- key: KEY,
316
- cert: CERT,
317
- // rejectUnauthorized: false,
318
- // enableTrace: true,
319
- // Allow HTTP/1.1 fallback for ALPN negotiation
320
- // allowHTTP1: true,
321
- // ALPNProtocols: ['h2', 'http/1.1', 'http/1.0'],
322
- // origins: ['*'],
323
- },
324
- createServer: createSecureServer,
325
- port: location.port,
326
- hostname: location.hostname,
327
- });
313
+ const server = await bindWithRetry(() =>
314
+ serve({
315
+ overrideGlobalObjects: true,
316
+ fetch: app.fetch,
317
+ serverOptions: {
318
+ key: KEY,
319
+ cert: CERT,
320
+ // rejectUnauthorized: false,
321
+ // enableTrace: true,
322
+ // Allow HTTP/1.1 fallback for ALPN negotiation
323
+ // allowHTTP1: true,
324
+ // ALPNProtocols: ['h2', 'http/1.1', 'http/1.0'],
325
+ // origins: ['*'],
326
+ },
327
+ createServer: createSecureServer,
328
+ port: location.port,
329
+ hostname: location.hostname,
330
+ })
331
+ );
328
332
 
329
333
  console.log(
330
- `\tServing Holodeck HTTP Mocks from ${chalk.yellow('https://') + chalk.magenta(location.hostname + ':') + chalk.yellow(location.port)}\n`
334
+ `\tServing Holodeck HTTP Mocks from ${styleText('yellow', 'https://') + styleText('magenta', location.hostname + ':') + styleText('yellow', String(location.port))}\n`
331
335
  );
332
336
 
333
337
  if (typeof threadId === 'number' && threadId !== 0) {
@@ -363,27 +367,32 @@ export async function launchProgram(config = {}) {
363
367
  }
364
368
  const options = { name, projectRoot, ...config };
365
369
  console.log(
366
- chalk.grey(
367
- `\n\t@${chalk.greenBright('warp-drive')}/${chalk.magentaBright(
370
+ styleText(
371
+ 'grey',
372
+ `\n\t@${styleText('greenBright', 'warp-drive')}/${styleText(
373
+ 'magentaBright',
368
374
  'holodeck'
369
375
  )} 🌅\n\t=================================\n`
370
376
  ) +
371
- chalk.grey(
372
- `\n\tHolodeck Access Granted\n\t\tprogram: ${chalk.magenta(name)}\n\t\tsettings: ${chalk.green(
377
+ styleText(
378
+ 'grey',
379
+ `\n\tHolodeck Access Granted\n\t\tprogram: ${styleText('magenta', name)}\n\t\tsettings: ${styleText(
380
+ 'green',
373
381
  JSON.stringify(config).split('\n').join(' ')
374
- )}\n\t\tdirectory: ${chalk.cyan(projectRoot)}\n\t\tengine: ${chalk.cyan(
382
+ )}\n\t\tdirectory: ${styleText('cyan', projectRoot)}\n\t\tengine: ${styleText(
383
+ 'cyan',
375
384
  'node'
376
- )}@${chalk.yellow(process.version)}\n`
385
+ )}@${styleText('yellow', process.version)}\n`
377
386
  )
378
387
  );
379
- console.log(chalk.grey(`\n\tStarting Holodeck Subroutines`));
388
+ console.log(styleText('grey', `\n\tStarting Holodeck Subroutines`));
380
389
 
381
390
  const project = await createServer(options);
382
391
 
383
392
  async function shutdown() {
384
- console.log(chalk.grey(`\n\tEnding Holodeck Subroutines`));
393
+ console.log(styleText('grey', `\n\tEnding Holodeck Subroutines`));
385
394
  project.server.close();
386
- console.log(chalk.grey(`\n\tHolodeck program ended`));
395
+ console.log(styleText('grey', `\n\tHolodeck program ended`));
387
396
  }
388
397
 
389
398
  const endProgram = createCloseHandler(shutdown);
package/server/utils.js CHANGED
@@ -59,7 +59,8 @@ export function getNiceUrl(url) {
59
59
  const urlObj = new URL(url);
60
60
  urlObj.searchParams.delete('__xTestId');
61
61
  urlObj.searchParams.delete('__xTestRequestNumber');
62
- return (urlObj.pathname + urlObj.searchParams.toString()).slice(1);
62
+ const params = urlObj.searchParams.toString();
63
+ return (urlObj.pathname + (params ? `?${params}` : '')).slice(1);
63
64
  }
64
65
 
65
66
  /*
@@ -126,3 +127,58 @@ export function createCloseHandler(cb) {
126
127
  cb();
127
128
  };
128
129
  }
130
+
131
+ /**
132
+ * Binds a server created by `createServerFn()`, retrying on the same port if
133
+ * it's already in use.
134
+ *
135
+ * Holodeck's port is never renegotiated on conflict -- every test app's
136
+ * client-side test-helper derives holodeck's URL as `window.location.port +
137
+ * 1` (the diagnostic server's own port, plus one), so holodeck must bind
138
+ * exactly the port it was asked for or the browser can never find it.
139
+ * Instead, this retries the *same* port a few times with a short delay, to
140
+ * ride out the narrow window where a concurrently-starting sibling process's
141
+ * diagnostic server hasn't released this exact port yet (see
142
+ * packages/diagnostic/server/utils/port-lock.js for the reservation scheme
143
+ * that's supposed to prevent that from happening at all -- this is a
144
+ * defense-in-depth fallback for anything outside that convention).
145
+ *
146
+ * `createServerFn` must synchronously create the server and start binding,
147
+ * returning the server instance. Some implementations (Bun.serve) throw
148
+ * synchronously on a bind failure; others (Node's http/http2 `.listen()`,
149
+ * which is what @hono/node-server uses under the hood) bind asynchronously
150
+ * and emit an 'error' event on the returned server instead of throwing. This
151
+ * handles both.
152
+ */
153
+ export async function bindWithRetry(createServerFn, { maxAttempts = 5, retryDelayMs = 150 } = {}) {
154
+ for (let attempt = 1; ; attempt++) {
155
+ try {
156
+ const server = createServerFn();
157
+ if (typeof server?.on !== 'function') {
158
+ // no event emitter to observe an async bind failure on -- the
159
+ // synchronous creation above already succeeded, so assume the bind
160
+ // did too (this is the Bun.serve path, which throws synchronously
161
+ // instead)
162
+ return server;
163
+ }
164
+ const bindError = await new Promise((resolve) => {
165
+ const onError = (e) => resolve(e);
166
+ server.once('error', onError);
167
+ setTimeout(() => {
168
+ server.off('error', onError);
169
+ resolve(null);
170
+ }, retryDelayMs);
171
+ });
172
+ if (!bindError) {
173
+ return server;
174
+ }
175
+ throw bindError;
176
+ } catch (e) {
177
+ if (e?.code !== 'EADDRINUSE' || attempt >= maxAttempts) {
178
+ throw e;
179
+ }
180
+ console.log(`\tPort in use, retrying bind (attempt ${attempt}/${maxAttempts})...`);
181
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
182
+ }
183
+ }
184
+ }
@@ -1,52 +0,0 @@
1
- import type { Handler, NextFn } from "@warp-drive/core/request";
2
- import type { RequestContext, StructuredDataDocument } from "@warp-drive/core/types/request";
3
- import type { Store } from "@warp-drive/legacy/store";
4
- import type { ScaffoldGenerator } from "./mock.js";
5
- /**
6
- * @public
7
- */
8
- export declare function setConfig({ host }: {
9
- host: string;
10
- }): void;
11
- /**
12
- * @public
13
- */
14
- export declare function setTestId(context: object, str: string | null): void;
15
- /**
16
- * @public
17
- */
18
- export declare function setIsRecording(value: boolean): void;
19
- /**
20
- * @public
21
- */
22
- export declare function getIsRecording(): boolean;
23
- /**
24
- * A request handler that intercepts requests and routes them through
25
- * the Holodeck mock server.
26
- *
27
- * This handler modifies the request URL to include test identifiers
28
- * and manages request counts for accurate mocking.
29
- *
30
- * Requires that the test context be configured with a testId using `setTestId`.
31
- *
32
- * @param owner - the test context object used to retrieve the test ID.
33
- */
34
- export declare class MockServerHandler implements Handler {
35
- owner: object;
36
- constructor(owner: object);
37
- request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>>;
38
- }
39
- /**
40
- * Creates an adapterFor function that wraps the provided adapterFor function
41
- * to override the adapter's _fetchRequest method to route requests through
42
- * the Holodeck mock server.
43
- *
44
- * @param owner - The test context object used to retrieve the test ID.
45
- */
46
- export declare function installAdapterFor(owner: object, store: Store): void;
47
- /**
48
- * Mock a request by sending the scaffold to the mock server.
49
- *
50
- * @public
51
- */
52
- export declare function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean): Promise<void>;
@@ -1,65 +0,0 @@
1
- /**
2
- * @public
3
- */
4
- export interface Scaffold {
5
- status: number;
6
- statusText?: string;
7
- headers: Record<string, string>;
8
- body: Record<string, string> | string | null;
9
- method: string;
10
- url: string;
11
- response: Record<string, unknown>;
12
- }
13
- /**
14
- * @public
15
- */
16
- export type ScaffoldGenerator = () => Scaffold;
17
- /**
18
- * @public
19
- */
20
- export type ResponseGenerator = () => Record<string, unknown>;
21
- /**
22
- * Sets up Mocking for a GET request on the mock server
23
- * for the supplied url.
24
- *
25
- * The response body is generated by the supplied response function.
26
- *
27
- * Available options:
28
- * - status: the status code to return (default: 200)
29
- * - headers: the headers to return (default: {})
30
- * - body: the body to match against for the request (default: null)
31
- * - RECORD: whether to record the request (default: false)
32
- *
33
- * @param url the url to mock, relative to the mock server host (e.g. `users/1`)
34
- * @param response a function which generates the response to return
35
- * @param options status, headers for the response, body to match against for the request, and whether to record the request
36
- * @return
37
- */
38
- export declare function GET(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
39
- RECORD?: boolean;
40
- }): Promise<void>;
41
- /**
42
- * Mock a POST request
43
- */
44
- export declare function POST(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
45
- RECORD?: boolean;
46
- }): Promise<void>;
47
- /**
48
- * mock a PUT request
49
- */
50
- export declare function PUT(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
51
- RECORD?: boolean;
52
- }): Promise<void>;
53
- /**
54
- * mock a PATCH request
55
- *
56
- */
57
- export declare function PATCH(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
58
- RECORD?: boolean;
59
- }): Promise<void>;
60
- /**
61
- * mock a DELETE request
62
- */
63
- export declare function DELETE(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
64
- RECORD?: boolean;
65
- }): Promise<void>;