Repository navigation
feat(cli): replace hand-rolled resolve hook with tsx #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c52f8da
5b2bf61
6899430
ad7fc20
7e52c7c
c54b047
168db55
737905d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // @ts-nocheck -- service.js resolves to service.ts under tsx; TypeScript sees | ||
| // the .js extension as missing (no .js file exists), so this file is excluded | ||
| // from type-checking. It acts as a loadability guard: `prisma-composer deploy` | ||
| // must be able to import ./service.js via tsx without failing. | ||
| import { module } from '@prisma/composer'; | ||
| import guardService from './service.js'; | ||
|
|
||
| export default module('js-ext-imports', ({ provision }) => { | ||
| provision(guardService); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "name": "@prisma/example-js-ext-imports", | ||
| "version": "0.10.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@prisma/composer": "workspace:0.10.0", | ||
| "@prisma/composer-cli": "workspace:0.10.0", | ||
| "@prisma/composer-prisma-cloud": "workspace:0.10.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/bun": "^1.3.13", | ||
| "typescript": "^6.0.3" | ||
| }, | ||
| "overrides": { | ||
| "effect": "4.0.0-beta.103", | ||
| "@effect/sql-d1": "4.0.0-beta.103", | ||
| "@effect/sql-pg": "4.0.0-beta.103", | ||
| "@effect/vitest": "4.0.0-beta.103", | ||
| "@effect/platform-bun": "4.0.0-beta.103", | ||
| "@effect/platform-node": "4.0.0-beta.103", | ||
| "@effect/platform-node-shared": "4.0.0-beta.103" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { defineConfig } from '@prisma/composer/config'; | ||
| import { nodeBuild } from '@prisma/composer/node/control'; | ||
| import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control'; | ||
|
|
||
| export default defineConfig({ | ||
| extensions: [prismaCloud(), nodeBuild()], | ||
| state: prismaState(), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import node from '@prisma/composer/node'; | ||
| import { compute } from '@prisma/composer-prisma-cloud'; | ||
|
|
||
| /** | ||
| * A minimal compute service used solely as the target of a .js-extension | ||
| * import in module.ts. Its purpose is to prove that tsx resolves ./service.js | ||
| * to this .ts source under Node — it is not meant to be deployed. | ||
| */ | ||
| export default compute({ | ||
| name: 'js-ext-guard', | ||
| deps: {}, | ||
| build: node({ module: import.meta.url, entry: 'server.js' }), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "extends": "../../tsconfig.base.json", | ||
| "compilerOptions": { | ||
| "types": ["bun"] | ||
| }, | ||
| "include": ["service.ts"] | ||
| } | ||
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| // @ts-nocheck -- fixture for the Node resolve-hook test; uses a .js | ||
| // specifier for a .ts file. The hook in entry-resolution.ts maps it to | ||
| // js-ext-service.ts at runtime. Not typechecked because TypeScript would | ||
| // report the .js file as missing (it doesn't exist; only the .ts does). | ||
| // @ts-nocheck -- fixture for the Node tsx-runner test; uses a .js | ||
| // specifier for a .ts file. tsx maps it to js-ext-service.ts at runtime. | ||
| // Not typechecked because TypeScript would report the .js file as missing | ||
| // (it doesn't exist; only the .ts does). | ||
| export { default } from './js-ext-service.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,3 @@ | ||
| // @ts-nocheck -- fixture for the Node resolve-hook test; uses a .mjs | ||
| // specifier for a .mts file. The hook in entry-resolution.ts maps it to | ||
| // mjs-ext-service.mts at runtime. | ||
| // @ts-nocheck -- fixture for the Node tsx-runner test; uses a .mjs | ||
| // specifier for a .mts file. tsx maps it to mjs-ext-service.mts at runtime. | ||
| export { default } from './mjs-ext-service.mjs'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| // @ts-nocheck -- fixture for the Node resolve-hook test; uses an extensionless | ||
| // specifier for a .ts file. The hook in entry-resolution.ts maps it to | ||
| // no-ext-service.ts at runtime. Not typechecked because TypeScript would | ||
| // report the module as missing (only the .ts source exists). | ||
| // @ts-nocheck -- fixture for the Node tsx-runner test; uses an extensionless | ||
| // specifier for a .ts file. tsx maps it to no-ext-service.ts at runtime. | ||
| // Not typechecked because TypeScript would report the module as missing | ||
| // (only the .ts source exists). | ||
| export { default } from './no-ext-service'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,15 @@ | ||
| import { describe, expect, test } from 'bun:test'; | ||
| import { spawnSync } from 'node:child_process'; | ||
| import * as path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { CliStructuredError } from '@internal/foundation/errors'; | ||
| import { loadEntry } from '../load-entry.ts'; | ||
|
|
||
| const fixture = (name: string) => path.join(import.meta.dir, 'fixtures', name); | ||
|
|
||
| // Resolve the tsx CLI path once. Under Bun, import.meta.resolve is synchronous. | ||
| const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli')); | ||
|
|
||
| describe('loadEntry()', () => { | ||
| test('accepts a service default export', async () => { | ||
| const entry = await loadEntry(fixture('valid-service.ts'), import.meta.dir); | ||
|
|
@@ -50,84 +54,53 @@ describe('loadEntry()', () => { | |
| expect(error.cause).toBeDefined(); | ||
| }); | ||
|
|
||
| // Bun's own module loader transforms JSX — this failure is node-specific | ||
| // (the CLI's shebang runtime; see node-compat.test.ts), so it's reproduced | ||
| // by spawning real node against a small standalone driver rather than | ||
| // calling loadEntry() in-process here. | ||
| test('a .tsx transitively imported by the entry gets a tailored error under node', () => { | ||
| const result = spawnSync('node', [fixture('run-load-entry.ts'), fixture('jsx-in-graph.ts')], { | ||
| encoding: 'utf8', | ||
| }); | ||
|
|
||
| expect(result.status).not.toBe(0); | ||
| expect(result.stderr).toContain(fixture('jsx-in-graph.tsx')); | ||
| expect(result.stderr).toContain("node's own module loader"); | ||
| expect(result.stderr).toContain('JSX transform'); | ||
| expect(result.stderr).toContain('examples/email/scripts/build.ts'); | ||
| // where.path carries the entry (ruling on `where`: the natural path). | ||
| expect(result.stderr).toContain(`Where: ${fixture('jsx-in-graph.ts')}`); | ||
| }, 15000); | ||
|
|
||
| // The resolve hook is node-specific (Bun resolves .js→.ts natively). | ||
| // These tests spawn real node so the hook registration path is exercised. | ||
| // tsx runs under Node and transpiles TypeScript for the entry graph. These | ||
| // tests spawn real node with the tsx CLI to exercise the tsx registration | ||
| // path that loadEntry() calls before importing the entry. | ||
|
|
||
| test('a .js-extension import resolves to the .ts source under node', () => { | ||
| test('a .js-extension import resolves to the .ts source under node via tsx', () => { | ||
| const result = spawnSync( | ||
| 'node', | ||
| [fixture('run-load-entry.ts'), fixture('entry-js-ext-import.ts')], | ||
| [tsxCli, fixture('run-load-entry.ts'), fixture('entry-js-ext-import.ts')], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
|
|
||
| expect(result.status).toBe(0); | ||
| }, 15000); | ||
|
|
||
| test('an extensionless import resolves to the .ts source under node', () => { | ||
| test('an extensionless import resolves to the .ts source under node via tsx', () => { | ||
| const result = spawnSync( | ||
| 'node', | ||
| [fixture('run-load-entry.ts'), fixture('entry-no-ext-import.ts')], | ||
| [tsxCli, fixture('run-load-entry.ts'), fixture('entry-no-ext-import.ts')], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
|
|
||
| expect(result.status).toBe(0); | ||
| }, 15000); | ||
|
|
||
| test('a .mjs-extension import resolves to the .mts source under node', () => { | ||
| test('a .mjs-extension import resolves to the .mts source under node via tsx', () => { | ||
| const result = spawnSync( | ||
| 'node', | ||
| [fixture('run-load-entry.ts'), fixture('entry-mjs-ext-import.ts')], | ||
| [tsxCli, fixture('run-load-entry.ts'), fixture('entry-mjs-ext-import.ts')], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
|
|
||
| expect(result.status).toBe(0); | ||
| }, 15000); | ||
|
|
||
| test('a .cjs-extension import resolves to the .cts source under node', () => { | ||
| const result = spawnSync( | ||
| 'node', | ||
| [fixture('run-load-entry.ts'), fixture('entry-cjs-ext-import.ts')], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
|
|
||
| // The hook resolved .cjs → .cts; resolution succeeded, but the fixture's | ||
| // export is not a Composer node, so the failure is ENTRY_EXPORT_INVALID. | ||
| expect(result.stderr).not.toContain('Cannot find module'); | ||
| expect(result.stderr).toContain('must default-export a node'); | ||
| }, 15000); | ||
|
|
||
| test('a genuinely missing relative import still fails with the original error under node', () => { | ||
| test('a genuinely missing relative import still fails with a module-not-found error under node', () => { | ||
| const result = spawnSync( | ||
| 'node', | ||
| [fixture('run-load-entry.ts'), fixture('entry-truly-missing-import.ts')], | ||
| [tsxCli, fixture('run-load-entry.ts'), fixture('entry-truly-missing-import.ts')], | ||
| { encoding: 'utf8' }, | ||
| ); | ||
|
|
||
| expect(result.status).not.toBe(0); | ||
| // The hook exhausted all candidates; the original ERR_MODULE_NOT_FOUND is re-thrown. | ||
| expect(result.stderr).toContain('truly-missing'); | ||
| }, 15000); | ||
|
Comment on lines
+57
to
100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Add a test that isolates These tests launch 🤖 Prompt for AI Agents |
||
|
|
||
| // Bun resolves .js→.ts natively — load in-process to confirm the hook is a | ||
| // no-op (nothing is registered) and the imports still work. | ||
| // Bun resolves .js→.ts natively — load in-process to confirm tsx registration | ||
| // is a no-op (nothing is registered under Bun) and the imports still work. | ||
| test('a .js-extension import loads correctly in-process under bun', async () => { | ||
| const entry = await loadEntry(fixture('entry-js-ext-import.ts'), import.meta.dir); | ||
| expect(entry.root.kind).toBe('service'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Expand the example typecheck scope.
tsc --noEmitcurrently checks onlyservice.ts. It skipsmodule.ts, which contains the.js-specifier loadability guard, andprisma-composer.config.ts, which is part of the runnable example. The reported typecheck can therefore pass while either file is broken.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents