rwsdk 1.0.0-alpha.8 → 1.0.0-alpha.9

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.
@@ -1,8 +1,10 @@
1
1
  import { test, beforeAll, afterAll, afterEach, } from "vitest";
2
+ import { basename } from "path";
2
3
  import { setupTarballEnvironment } from "./tarball.mjs";
3
4
  import { runDevServer } from "./dev.mjs";
4
5
  import { runRelease, deleteWorker, deleteD1Database, isRelatedToTest, } from "./release.mjs";
5
6
  import { launchBrowser } from "./browser.mjs";
7
+ const SETUP_PLAYGROUND_ENV_TIMEOUT = 10 * 60 * 1000;
6
8
  // Environment variable flags for skipping tests
7
9
  const SKIP_DEV_SERVER_TESTS = process.env.RWSDK_SKIP_DEV === "1";
8
10
  const SKIP_DEPLOYMENT_TESTS = process.env.RWSDK_SKIP_DEPLOY === "1";
@@ -107,13 +109,13 @@ export function setupPlaygroundEnvironment(sourceProjectDir) {
107
109
  console.log(`Setting up playground environment from ${projectDir}...`);
108
110
  const tarballEnv = await setupTarballEnvironment({
109
111
  projectDir,
110
- packageManager: "pnpm",
112
+ packageManager: process.env.PACKAGE_MANAGER || "pnpm",
111
113
  });
112
114
  globalPlaygroundEnv = {
113
115
  projectDir: tarballEnv.targetDir,
114
116
  cleanup: tarballEnv.cleanup,
115
117
  };
116
- });
118
+ }, SETUP_PLAYGROUND_ENV_TIMEOUT);
117
119
  }
118
120
  /**
119
121
  * Gets the current playground environment.
@@ -134,7 +136,8 @@ export async function createDevServer() {
134
136
  throw new Error("Dev server tests are skipped via RWSDK_SKIP_DEV=1");
135
137
  }
136
138
  const env = getPlaygroundEnvironment();
137
- const devResult = await runDevServer("pnpm", env.projectDir);
139
+ const packageManager = process.env.PACKAGE_MANAGER || "pnpm";
140
+ const devResult = await runDevServer(packageManager, env.projectDir);
138
141
  const serverId = `devServer-${Date.now()}-${Math.random()
139
142
  .toString(36)
140
143
  .substring(2, 9)}`;
@@ -161,8 +164,26 @@ export async function createDeployment() {
161
164
  throw new Error("Deployment tests are skipped via RWSDK_SKIP_DEPLOY=1");
162
165
  }
163
166
  const env = getPlaygroundEnvironment();
164
- const resourceUniqueKey = Math.random().toString(36).substring(2, 15);
167
+ // Extract the unique key from the project directory name instead of generating a new one
168
+ // The directory name format is: {projectName}-e2e-test-{randomId}
169
+ const dirName = basename(env.projectDir);
170
+ const match = dirName.match(/-e2e-test-([a-f0-9]+)$/);
171
+ const resourceUniqueKey = match
172
+ ? match[1]
173
+ : Math.random().toString(36).substring(2, 15);
165
174
  const deployResult = await runRelease(env.projectDir, env.projectDir, resourceUniqueKey);
175
+ // Poll the URL to ensure it's live before proceeding
176
+ await poll(async () => {
177
+ try {
178
+ const response = await fetch(deployResult.url);
179
+ // We consider any response (even 4xx or 5xx) as success,
180
+ // as it means the worker is routable.
181
+ return response.status > 0;
182
+ }
183
+ catch (e) {
184
+ return false;
185
+ }
186
+ }, 60000);
166
187
  const deploymentId = `deployment-${Date.now()}-${Math.random()
167
188
  .toString(36)
168
189
  .substring(2, 9)}`;
@@ -189,6 +210,7 @@ export async function createDeployment() {
189
210
  url: deployResult.url,
190
211
  workerName: deployResult.workerName,
191
212
  resourceUniqueKey,
213
+ projectDir: env.projectDir,
192
214
  };
193
215
  }
194
216
  /**
@@ -238,26 +260,91 @@ export async function createBrowser() {
238
260
  };
239
261
  return browser;
240
262
  }
263
+ /**
264
+ * Executes a test function with a retry mechanism for specific error codes.
265
+ * @param name - The name of the test, used for logging.
266
+ * @param attemptFn - A function that executes one attempt of the test.
267
+ * It should set up resources, run the test logic, and
268
+ * return a cleanup function. The cleanup function will be
269
+ * called automatically on failure.
270
+ */
271
+ export async function runTestWithRetries(name, attemptFn) {
272
+ const MAX_RETRIES_PER_CODE = 6;
273
+ const retryCounts = {};
274
+ let attempt = 0;
275
+ while (true) {
276
+ attempt++;
277
+ let cleanup;
278
+ try {
279
+ const res = await attemptFn();
280
+ cleanup = res.cleanup;
281
+ if (attempt > 1) {
282
+ console.log(`[runTestWithRetries] Test "${name}" succeeded on attempt ${attempt}.`);
283
+ }
284
+ // On success, we don't run cleanup here. It will be handled by afterEach.
285
+ return; // Success
286
+ }
287
+ catch (e) {
288
+ // On failure, run the cleanup from the failed attempt.
289
+ // The cleanup function is attached to the error object on failure.
290
+ const errorCleanup = e.cleanup;
291
+ if (typeof errorCleanup === "function") {
292
+ await errorCleanup().catch((err) => console.warn(`[runTestWithRetries] Cleanup failed for "${name}" during retry:`, err));
293
+ }
294
+ const errorCode = e?.code;
295
+ if (typeof errorCode === "string" && errorCode) {
296
+ const count = (retryCounts[errorCode] || 0) + 1;
297
+ retryCounts[errorCode] = count;
298
+ if (count <= MAX_RETRIES_PER_CODE) {
299
+ console.log(`[runTestWithRetries] Attempt ${attempt} for "${name}" failed with code ${errorCode}. Retrying (failure ${count}/${MAX_RETRIES_PER_CODE} for this code)...`);
300
+ await new Promise((resolve) => setTimeout(resolve, 1000));
301
+ continue; // Next attempt
302
+ }
303
+ else {
304
+ console.error(`[runTestWithRetries] Test "${name}" failed with code ${errorCode} after ${MAX_RETRIES_PER_CODE} retries for this code.`);
305
+ throw e; // Give up
306
+ }
307
+ }
308
+ else {
309
+ console.error(`[runTestWithRetries] Test "${name}" failed on attempt ${attempt} with a non-retryable error:`, e);
310
+ throw e;
311
+ }
312
+ }
313
+ }
314
+ }
241
315
  /**
242
316
  * High-level test wrapper for dev server tests.
243
317
  * Automatically skips if RWSDK_SKIP_DEV=1
244
318
  */
245
319
  export function testDev(name, testFn) {
246
320
  if (SKIP_DEV_SERVER_TESTS) {
247
- test.skip(name, () => { });
321
+ test.skip(name, testFn);
248
322
  return;
249
323
  }
250
324
  test(name, async () => {
251
- const devServer = await createDevServer();
252
- const browser = await createBrowser();
253
- const page = await browser.newPage();
254
- await testFn({
255
- devServer,
256
- browser,
257
- page,
258
- url: devServer.url,
325
+ await runTestWithRetries(name, async () => {
326
+ const devServer = await createDevServer();
327
+ const browser = await createBrowser();
328
+ const page = await browser.newPage();
329
+ const cleanup = async () => {
330
+ await browser.close();
331
+ await devServer.stopDev();
332
+ };
333
+ try {
334
+ await testFn({
335
+ devServer,
336
+ browser,
337
+ page,
338
+ url: devServer.url,
339
+ });
340
+ return { cleanup };
341
+ }
342
+ catch (error) {
343
+ // Ensure cleanup is available to the retry wrapper even if testFn fails.
344
+ // We re-throw the error to be handled by runTestWithRetries.
345
+ throw Object.assign(error, { cleanup });
346
+ }
259
347
  });
260
- // Automatic cleanup handled by afterEach hooks
261
348
  });
262
349
  }
263
350
  /**
@@ -266,26 +353,70 @@ export function testDev(name, testFn) {
266
353
  testDev.skip = (name, testFn) => {
267
354
  test.skip(name, testFn || (() => { }));
268
355
  };
356
+ testDev.only = (name, testFn) => {
357
+ if (SKIP_DEV_SERVER_TESTS) {
358
+ test.skip(name, () => { });
359
+ return;
360
+ }
361
+ test.only(name, async () => {
362
+ await runTestWithRetries(name, async () => {
363
+ const devServer = await createDevServer();
364
+ const browser = await createBrowser();
365
+ const page = await browser.newPage();
366
+ const cleanup = async () => {
367
+ await browser.close();
368
+ await devServer.stopDev();
369
+ };
370
+ try {
371
+ await testFn({
372
+ devServer,
373
+ browser,
374
+ page,
375
+ url: devServer.url,
376
+ });
377
+ return { cleanup };
378
+ }
379
+ catch (error) {
380
+ // Ensure cleanup is available to the retry wrapper even if testFn fails.
381
+ // We re-throw the error to be handled by runTestWithRetries.
382
+ throw Object.assign(error, { cleanup });
383
+ }
384
+ });
385
+ });
386
+ };
269
387
  /**
270
388
  * High-level test wrapper for deployment tests.
271
389
  * Automatically skips if RWSDK_SKIP_DEPLOY=1
272
390
  */
273
391
  export function testDeploy(name, testFn) {
274
392
  if (SKIP_DEPLOYMENT_TESTS) {
275
- test.skip(name, () => { });
393
+ test.skip(name, testFn);
276
394
  return;
277
395
  }
278
396
  test(name, async () => {
279
- const deployment = await createDeployment();
280
- const browser = await createBrowser();
281
- const page = await browser.newPage();
282
- await testFn({
283
- deployment,
284
- browser,
285
- page,
286
- url: deployment.url,
397
+ await runTestWithRetries(name, async () => {
398
+ const deployment = await createDeployment();
399
+ const browser = await createBrowser();
400
+ const page = await browser.newPage();
401
+ const cleanup = async () => {
402
+ // We don't await this because we want to let it run in the background
403
+ // The afterEach hook for deployments already does this.
404
+ await cleanupDeployment(deployment);
405
+ await browser.close();
406
+ };
407
+ try {
408
+ await testFn({
409
+ deployment,
410
+ browser,
411
+ page,
412
+ url: deployment.url,
413
+ });
414
+ return { cleanup };
415
+ }
416
+ catch (error) {
417
+ throw Object.assign(error, { cleanup });
418
+ }
287
419
  });
288
- // Automatic cleanup handled by afterEach hooks
289
420
  });
290
421
  }
291
422
  /**
@@ -294,88 +425,60 @@ export function testDeploy(name, testFn) {
294
425
  testDeploy.skip = (name, testFn) => {
295
426
  test.skip(name, testFn || (() => { }));
296
427
  };
297
- /**
298
- * Unified test function that runs the same test against both dev server and deployment.
299
- * Automatically skips based on environment variables.
300
- */
301
- export function testDevAndDeploy(name, testFn) {
302
- if (SKIP_DEV_SERVER_TESTS) {
303
- test.skip(`${name} (dev)`, () => { });
304
- }
305
- else {
306
- test(`${name} (dev)`, async () => {
307
- const devServer = await createDevServer();
308
- const browser = await createBrowser();
309
- const page = await browser.newPage();
310
- await testFn({
311
- devServer,
312
- browser,
313
- page,
314
- url: devServer.url,
315
- });
316
- // Automatic cleanup handled by afterEach hooks
317
- });
318
- }
428
+ testDeploy.only = (name, testFn) => {
319
429
  if (SKIP_DEPLOYMENT_TESTS) {
320
- test.skip(`${name} (deployment)`, () => { });
430
+ test.skip(name, () => { });
431
+ return;
321
432
  }
322
- else {
323
- test(`${name} (deployment)`, async () => {
433
+ test.only(name, async () => {
434
+ await runTestWithRetries(name, async () => {
324
435
  const deployment = await createDeployment();
325
436
  const browser = await createBrowser();
326
437
  const page = await browser.newPage();
327
- await testFn({
328
- deployment,
329
- browser,
330
- page,
331
- url: deployment.url,
332
- });
333
- // Automatic cleanup handled by afterEach hooks
438
+ const cleanup = async () => {
439
+ // We don't await this because we want to let it run in the background
440
+ // The afterEach hook for deployments already does this.
441
+ await cleanupDeployment(deployment);
442
+ await browser.close();
443
+ };
444
+ try {
445
+ await testFn({
446
+ deployment,
447
+ browser,
448
+ page,
449
+ url: deployment.url,
450
+ });
451
+ return { cleanup };
452
+ }
453
+ catch (error) {
454
+ throw Object.assign(error, { cleanup });
455
+ }
334
456
  });
335
- }
457
+ });
458
+ };
459
+ /**
460
+ * Unified test function that runs the same test against both dev server and deployment.
461
+ * Automatically skips based on environment variables.
462
+ */
463
+ export function testDevAndDeploy(name, testFn) {
464
+ testDev(`${name} (dev)`, testFn);
465
+ testDeploy(`${name} (deployment)`, testFn);
336
466
  }
337
467
  /**
338
468
  * Skip version of testDevAndDeploy
339
469
  */
340
470
  testDevAndDeploy.skip = (name, testFn) => {
341
- test.skip(`${name} (dev)`, testFn || (() => { }));
342
- test.skip(`${name} (deployment)`, testFn || (() => { }));
471
+ test.skip(name, testFn || (() => { }));
343
472
  };
344
- /**
345
- * Only version of testDevAndDeploy
346
- */
347
473
  testDevAndDeploy.only = (name, testFn) => {
348
- if (!SKIP_DEV_SERVER_TESTS) {
349
- test.only(`${name} (dev)`, async () => {
350
- const devServer = await createDevServer();
351
- const browser = await createBrowser();
352
- const page = await browser.newPage();
353
- await testFn({
354
- devServer,
355
- browser,
356
- page,
357
- url: devServer.url,
358
- });
359
- });
360
- }
361
- if (!SKIP_DEPLOYMENT_TESTS) {
362
- test.only(`${name} (deployment)`, async () => {
363
- const deployment = await createDeployment();
364
- const browser = await createBrowser();
365
- const page = await browser.newPage();
366
- await testFn({
367
- deployment,
368
- browser,
369
- page,
370
- url: deployment.url,
371
- });
372
- });
373
- }
474
+ testDev.only(`${name} (dev)`, testFn);
475
+ testDeploy.only(`${name} (deployment)`, testFn);
374
476
  };
375
477
  /**
376
478
  * Utility function for polling/retrying assertions
377
479
  */
378
- export async function poll(fn, timeout = 5000, interval = 100) {
480
+ export async function poll(fn, timeout = 2 * 60 * 1000, // 2 minutes
481
+ interval = 100) {
379
482
  const startTime = Date.now();
380
483
  while (Date.now() - startTime < timeout) {
381
484
  try {
@@ -16,6 +16,7 @@ export interface SmokeTestOptions {
16
16
  realtime?: boolean;
17
17
  skipHmr?: boolean;
18
18
  skipStyleTests?: boolean;
19
+ tarballPath?: string;
19
20
  }
20
21
  /**
21
22
  * Resources created during a test run that need to be cleaned up
@@ -6,10 +6,11 @@ import type { ViteBuilder } from "vite";
6
6
  *
7
7
  * @see docs/architecture/productionBuildProcess.md
8
8
  */
9
- export declare function buildApp({ builder, clientEntryPoints, clientFiles, serverFiles, projectRootDir, }: {
9
+ export declare function buildApp({ builder, clientEntryPoints, clientFiles, serverFiles, projectRootDir, workerEntryPathname, }: {
10
10
  builder: ViteBuilder;
11
11
  clientEntryPoints: Set<string>;
12
12
  clientFiles: Set<string>;
13
13
  serverFiles: Set<string>;
14
14
  projectRootDir: string;
15
+ workerEntryPathname: string;
15
16
  }): Promise<void>;
@@ -9,13 +9,14 @@ const log = debug("rwsdk:vite:build-app");
9
9
  *
10
10
  * @see docs/architecture/productionBuildProcess.md
11
11
  */
12
- export async function buildApp({ builder, clientEntryPoints, clientFiles, serverFiles, projectRootDir, }) {
12
+ export async function buildApp({ builder, clientEntryPoints, clientFiles, serverFiles, projectRootDir, workerEntryPathname, }) {
13
13
  const workerEnv = builder.environments.worker;
14
14
  await runDirectivesScan({
15
15
  rootConfig: builder.config,
16
16
  environments: builder.environments,
17
17
  clientFiles,
18
18
  serverFiles,
19
+ entries: [workerEntryPathname],
19
20
  });
20
21
  console.log("Building worker to discover used client components...");
21
22
  process.env.RWSDK_BUILD_PASS = "worker";
@@ -42,11 +43,14 @@ export async function buildApp({ builder, clientEntryPoints, clientFiles, server
42
43
  // Re-configure the worker environment for the linking pass
43
44
  const workerConfig = workerEnv.config;
44
45
  workerConfig.build.emptyOutDir = false;
46
+ // context(justinvdm, 22 Sep 2025): This is a workaround to satisfy the
47
+ // Cloudflare plugin's expectation of an entry chunk named `index`. The plugin
48
+ // now manages the worker build, so we no longer set rollup options
49
+ // directly. Instead, we re-point the original entry to the intermediate
50
+ // worker bundle from the first pass. This allows the linker pass to re-use
51
+ // the same plugin-driven configuration while bundling the final worker.
45
52
  workerConfig.build.rollupOptions.input = {
46
- worker: resolve(projectRootDir, "dist", "worker", "worker.js"),
47
- };
48
- workerConfig.build.rollupOptions.output = {
49
- entryFileNames: "worker.js",
53
+ index: resolve(projectRootDir, "dist", "worker", "index.js"),
50
54
  };
51
55
  await builder.build(workerEnv);
52
56
  console.log("Build complete!");
@@ -1,13 +1,11 @@
1
1
  import path, { resolve } from "node:path";
2
2
  import enhancedResolve from "enhanced-resolve";
3
- import debug from "debug";
4
3
  import { INTERMEDIATE_SSR_BRIDGE_PATH } from "../lib/constants.mjs";
5
4
  import { buildApp } from "./buildApp.mjs";
6
5
  import { externalModules } from "./constants.mjs";
7
- const log = debug("rwsdk:vite:config");
8
6
  export const configPlugin = ({ silent, projectRootDir, workerEntryPathname, clientFiles, serverFiles, clientEntryPoints, }) => ({
9
7
  name: "rwsdk:config",
10
- config: async (_) => {
8
+ config: async (_, { command }) => {
11
9
  const mode = process.env.NODE_ENV;
12
10
  const workerConfig = {
13
11
  resolve: {
@@ -49,14 +47,6 @@ export const configPlugin = ({ silent, projectRootDir, workerEntryPathname, clie
49
47
  emitAssets: true,
50
48
  emptyOutDir: false,
51
49
  ssr: true,
52
- rollupOptions: {
53
- output: {
54
- inlineDynamicImports: true,
55
- },
56
- input: {
57
- worker: workerEntryPathname,
58
- },
59
- },
60
50
  },
61
51
  };
62
52
  const baseConfig = {
@@ -176,6 +166,7 @@ export const configPlugin = ({ silent, projectRootDir, workerEntryPathname, clie
176
166
  clientEntryPoints,
177
167
  clientFiles,
178
168
  serverFiles,
169
+ workerEntryPathname,
179
170
  });
180
171
  },
181
172
  },
@@ -1,8 +1,9 @@
1
1
  import { Plugin } from "vite";
2
2
  export declare const generateVendorBarrelContent: (files: Set<string>, projectRootDir: string) => string;
3
3
  export declare const generateAppBarrelContent: (files: Set<string>, projectRootDir: string) => string;
4
- export declare const directiveModulesDevPlugin: ({ clientFiles, serverFiles, projectRootDir, }: {
4
+ export declare const directiveModulesDevPlugin: ({ clientFiles, serverFiles, projectRootDir, workerEntryPathname, }: {
5
5
  clientFiles: Set<string>;
6
6
  serverFiles: Set<string>;
7
7
  projectRootDir: string;
8
+ workerEntryPathname: string;
8
9
  }) => Plugin;
@@ -30,7 +30,7 @@ export const generateAppBarrelContent = (files, projectRootDir) => {
30
30
  })
31
31
  .join("\n");
32
32
  };
33
- export const directiveModulesDevPlugin = ({ clientFiles, serverFiles, projectRootDir, }) => {
33
+ export const directiveModulesDevPlugin = ({ clientFiles, serverFiles, projectRootDir, workerEntryPathname, }) => {
34
34
  const { promise: scanPromise, resolve: resolveScanPromise } = Promise.withResolvers();
35
35
  const tempDir = mkdtempSync(path.join(os.tmpdir(), "rwsdk-"));
36
36
  const APP_CLIENT_BARREL_PATH = path.join(tempDir, "app-client-barrel.js");
@@ -47,6 +47,7 @@ export const directiveModulesDevPlugin = ({ clientFiles, serverFiles, projectRoo
47
47
  environments: server.environments,
48
48
  clientFiles,
49
49
  serverFiles,
50
+ entries: [workerEntryPathname],
50
51
  }).then(() => {
51
52
  // context(justinvdm, 11 Sep 2025): For vendor barrels, we write the
52
53
  // files directly to disk after the scan. For app barrels, we use a
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Efficiently checks if a React directive (e.g., "use server", "use client")
3
- * is present in the code. Optimized for performance with a two-step approach:
4
- * 1. Quick string search to see if directive exists anywhere
5
- * 2. Line-by-line check only if the directive might be present
3
+ * is present in the code.
4
+ *
5
+ * This function is optimized for performance by only checking the first few
6
+ * lines of the code, as directives must appear at the very top of a file.
7
+ * It handles comments, whitespace, and any valid directive prologue
8
+ * (e.g., "use strict").
6
9
  */
7
10
  export declare function hasDirective(code: string, directive: string): boolean;
@@ -1,54 +1,70 @@
1
1
  /**
2
2
  * Efficiently checks if a React directive (e.g., "use server", "use client")
3
- * is present in the code. Optimized for performance with a two-step approach:
4
- * 1. Quick string search to see if directive exists anywhere
5
- * 2. Line-by-line check only if the directive might be present
3
+ * is present in the code.
4
+ *
5
+ * This function is optimized for performance by only checking the first few
6
+ * lines of the code, as directives must appear at the very top of a file.
7
+ * It handles comments, whitespace, and any valid directive prologue
8
+ * (e.g., "use strict").
6
9
  */
7
10
  export function hasDirective(code, directive) {
8
- // Quick performance check: if directive doesn't exist anywhere, skip line checking
9
- const singleQuoteDirective = `'${directive}'`;
10
- const doubleQuoteDirective = `"${directive}"`;
11
- if (!code.includes(singleQuoteDirective) &&
12
- !code.includes(doubleQuoteDirective)) {
13
- return false;
14
- }
15
- // Split into lines and check each one
16
- const lines = code.split("\n");
11
+ const lines = code.slice(0, 512).split("\n"); // Check first ~512 chars
17
12
  let inMultiLineComment = false;
13
+ let foundUseClient = false;
14
+ let foundTargetDirective = false;
15
+ const doubleQuoteDirective = `"${directive}"`;
16
+ const singleQuoteDirective = `'${directive}'`;
17
+ const doubleQuoteUseClient = `"use client"`;
18
+ const singleQuoteUseClient = `'use client'`;
18
19
  for (const line of lines) {
19
20
  const trimmedLine = line.trim();
20
- // Skip empty lines
21
21
  if (trimmedLine.length === 0) {
22
22
  continue;
23
23
  }
24
- // Handle multi-line comments
25
- if (trimmedLine.startsWith("/*")) {
26
- inMultiLineComment = true;
27
- // Check if the comment ends on the same line
24
+ if (inMultiLineComment) {
28
25
  if (trimmedLine.includes("*/")) {
29
26
  inMultiLineComment = false;
30
27
  }
31
28
  continue;
32
29
  }
33
- if (inMultiLineComment) {
34
- // Check if this line ends the multi-line comment
35
- if (trimmedLine.includes("*/")) {
36
- inMultiLineComment = false;
30
+ if (trimmedLine.startsWith("/*")) {
31
+ if (!trimmedLine.includes("*/")) {
32
+ inMultiLineComment = true;
37
33
  }
38
34
  continue;
39
35
  }
40
- // Skip single-line comments
41
36
  if (trimmedLine.startsWith("//")) {
42
37
  continue;
43
38
  }
44
- // Check if this line starts with the directive
39
+ const cleanedLine = trimmedLine.endsWith(";")
40
+ ? trimmedLine.slice(0, -1)
41
+ : trimmedLine;
42
+ if (trimmedLine.startsWith(doubleQuoteUseClient) ||
43
+ trimmedLine.startsWith(singleQuoteUseClient)) {
44
+ foundUseClient = true;
45
+ if (directive === "use client") {
46
+ return true;
47
+ }
48
+ }
45
49
  if (trimmedLine.startsWith(doubleQuoteDirective) ||
46
50
  trimmedLine.startsWith(singleQuoteDirective)) {
47
- return true;
51
+ foundTargetDirective = true;
52
+ if (directive !== "use server") {
53
+ return true;
54
+ }
48
55
  }
49
- // If we hit a non-empty, non-comment line that's not a directive, we can stop
50
- // (directives must be at the top of the file/scope, after comments)
56
+ // Any other string literal is part of a valid directive prologue.
57
+ // We can continue searching.
58
+ if (trimmedLine.startsWith('"') || trimmedLine.startsWith("'")) {
59
+ continue;
60
+ }
61
+ // If we encounter any other non-directive, non-comment, non-string-literal
62
+ // line of code, the directive prologue is over. Stop.
51
63
  break;
52
64
  }
53
- return false;
65
+ // If looking for 'use server' and 'use client' was found, return false (client takes priority)
66
+ if (directive === "use server" && foundUseClient) {
67
+ return false;
68
+ }
69
+ return foundTargetDirective;
54
70
  }