diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index a620195c5..bf669ca25 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -37,5 +37,15 @@ jobs: - name: Unit Tests run: npm run jest + # Work around intermittent hosted-runner Docker startup failures: + # https://github.com/actions/runner-images/issues/13729 + - name: Ensure Docker is running + shell: powershell + run: | + $dockerService = Get-Service -Name "docker" + if ($dockerService.Status -ne "Running") { + Start-Service -Name "docker" + } + - name: Test Command line run: ./__tests__/e2e_test.bat diff --git a/README.md b/README.md index 4ba04c3f6..eccc97f6c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ VIP-CLI is a tool for interacting with and managing your [WordPress VIP applicat - [CONTRIBUTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/CONTRIBUTING.md) for information on how to contribute patches and features, also issue and pull request labels. - [DEBUGGING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/DEBUGGING.md) for information on how to debug the software. - [TESTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/TESTING.md) for details on testing the software and individual tasks. +- [EDGE-WORKERS.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/EDGE-WORKERS.md) for the edge-worker scaffold, validation, deployment, and operational safety contract. - [RELEASING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/RELEASING.md) for details on deploying a new release. - [SECURITY.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/SECURITY.md) for information if you **found a security issue**. diff --git a/__tests__/bin/vip-edge-workers-build.js b/__tests__/bin/vip-edge-workers-build.js new file mode 100644 index 000000000..064993b8c --- /dev/null +++ b/__tests__/bin/vip-edge-workers-build.js @@ -0,0 +1,143 @@ +import path from 'node:path'; + +import { edgeWorkersBuildCommand } from '../../src/bin/vip-edge-workers-build'; +import * as exit from '../../src/lib/cli/exit'; +import * as lib from '../../src/lib/edge-workers'; +import * as project from '../../src/lib/edge-workers/project'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + discoverWorkers: jest.fn(), + findWorker: jest.fn(), + resolveProjectDir: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +const worker = name => ( { + dir: `/project/workers/${ name }`, + manifest: { name, entry: 'assembly/index.ts' }, +} ); + +describe( 'edgeWorkersBuildCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.findWorker.mockImplementation( ( _projectDir, name ) => worker( name ) ); + project.discoverWorkers.mockReturnValue( [ worker( 'alpha' ), worker( 'beta' ) ] ); + lib.buildWorker.mockImplementation( ( _projectDir, selectedWorker ) => ( { + wasmPath: `/project/build/${ selectedWorker.manifest.name }.wasm`, + sizeBytes: selectedWorker.manifest.name.length, + } ) ); + } ); + + it( 'builds one named worker', async () => { + await edgeWorkersBuildCommand( [ 'alpha' ] ); + + expect( project.findWorker ).toHaveBeenCalledWith( '/project', 'alpha' ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( lib.buildWorker ).toHaveBeenCalledWith( '/project', worker( 'alpha' ) ); + } ); + + it( 'builds all discovered workers when no name is supplied', async () => { + await edgeWorkersBuildCommand(); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'builds all discovered workers with --all', async () => { + await edgeWorkersBuildCommand( [], { all: true } ); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'rejects a worker name together with --all', async () => { + await expect( edgeWorkersBuildCommand( [ 'alpha' ], { all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Supply either a worker name or --all, not both.' + ); + expect( project.findWorker ).not.toHaveBeenCalled(); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( lib.buildWorker ).not.toHaveBeenCalled(); + } ); + + it( 'reports when the project has no workers', async () => { + project.discoverWorkers.mockReturnValue( [] ); + + await expect( edgeWorkersBuildCommand() ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'No workers found in this project. Create one with `vip edge-workers new`.' + ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { + name: undefined, + error: 'build_failed', + } ); + expect( lib.buildWorker ).not.toHaveBeenCalled(); + } ); + + it( 'keeps compiler diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_BUILD_SECRET'; + const sourcePath = '/private/customer/project/workers/alpha/assembly/index.ts'; + const diagnostic = `Compilation failed at ${ sourcePath }: const token = "${ secret }";\n\u001b[31merror`; + lib.buildWorker.mockImplementation( () => { + throw new Error( diagnostic ); + } ); + + await expect( edgeWorkersBuildCommand( [ 'alpha' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', { + name: 'alpha', + error: 'build_failed', + } ); + expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_build_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'prints the relative artifact path and byte size', async () => { + await edgeWorkersBuildCommand( [ 'alpha' ] ); + + expect( console.log ).toHaveBeenCalledWith( + `✓ Built "alpha" → ${ path.relative( '/project', '/project/build/alpha.wasm' ) } (5 bytes)` + ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_success', { + count: 1, + } ); + const buildOrder = lib.buildWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( buildOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-delete.js b/__tests__/bin/vip-edge-workers-delete.js new file mode 100644 index 000000000..5e387ac02 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-delete.js @@ -0,0 +1,165 @@ +import { edgeWorkersDeleteCommand } from '../../src/bin/vip-edge-workers-delete'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: 'mock-app-query', + deleteEdgeWorker: jest.fn(), + findEdgeWorkerByName: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + force: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersDeleteCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.deleteEdgeWorker.mockResolvedValue(); + confirm.mockResolvedValue( true ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'resolves the target and does not prompt or delete when it is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersDeleteCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'missing' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + expect.anything() + ); + } ); + + it( 'confirms the exact resolved target before deleting', async () => { + const order = []; + edgeWorkersApi.findEdgeWorkerByName.mockImplementation( async () => { + order.push( 'resolve' ); + return worker; + } ); + confirm.mockImplementation( async message => { + expect( message ).toBe( + 'Permanently delete edge worker "headers" from example-app.production?' + ); + order.push( 'confirm' ); + return true; + } ); + edgeWorkersApi.deleteEdgeWorker.mockImplementation( async () => { + order.push( 'delete' ); + } ); + + await edgeWorkersDeleteCommand( [ 'headers' ], opts ); + + expect( order ).toEqual( [ 'resolve', 'confirm', 'delete' ] ); + } ); + + it( 'does not delete when confirmation is declined', async () => { + confirm.mockResolvedValue( false ); + + await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to delete edge worker: Command cancelled by user.' + ); + } ); + + it( 'uses force as the explicit prompt bypass', async () => { + await edgeWorkersDeleteCommand( [ 'headers' ], { ...opts, force: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 ); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.deleteEdgeWorker.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to delete edge worker: API unavailable' + ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_error', + { name: 'headers', error: 'delete_failed' } + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + expect.anything() + ); + } ); + + it( 'prints and tracks success only after deletion succeeds', async () => { + await edgeWorkersDeleteCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 ); + expect( console.log ).toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_delete_command_success', + { name: 'headers' } + ); + const deletionOrder = edgeWorkersApi.deleteEdgeWorker.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( deletionOrder ).toBeLessThan( outputOrder ); + expect( deletionOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js new file mode 100644 index 000000000..4b0a6dcdc --- /dev/null +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -0,0 +1,506 @@ +import { edgeWorkersDeployCommand } from '../../src/bin/vip-edge-workers-deploy'; +import command from '../../src/lib/cli/command'; +import * as exit from '../../src/lib/cli/exit'; +import * as format from '../../src/lib/cli/format'; +import * as confirmation from '../../src/lib/edge-workers/confirmation'; +import * as deployment from '../../src/lib/edge-workers/deployment'; +import * as project from '../../src/lib/edge-workers/project'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const options = []; + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: ( ...args ) => { + options.push( args ); + return commandMock; + }, + }; + const createCommand = jest.fn( () => commandMock ); + createCommand.options = options; + return createCommand; +} ); + +jest.mock( '../../src/lib/cli/format', () => ( { + formatData: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/confirmation', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/confirmation' ); + return { + ...actual, + isInteractiveEdgeWorkers: jest.fn(), + }; +} ); + +jest.mock( '../../src/lib/edge-workers/deployment', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/deployment' ); + return { + ...actual, + prepareEdgeWorkerDeploymentPlan: jest.fn(), + deploymentPlanRows: jest.fn(), + applyEdgeWorkerDeploymentPlan: jest.fn(), + }; +} ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + resolveProjectDir: jest.fn(), + findWorker: jest.fn(), + discoverWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + skipBuild: true, + skipValidate: false, + skipSource: false, + skipConfirmation: false, + enable: false, +}; + +const worker = name => ( { + dir: `/project/workers/${ name }`, + manifest: { name, entry: 'assembly/index.ts' }, +} ); + +const planItem = ( name, action = 'create' ) => ( { + action, + worker: worker( name ), + existing: action === 'update' ? { id: 42, name, active: true } : null, + artifact: { + wasmPath: `/project/build/${ name }.wasm`, + base64: `binary-${ name }`, + sizeBytes: name.length, + }, + validation: 'passed', + phases: [ 'client_response' ], + input: { name, wasmBinary: `binary-${ name }` }, + currentLocation: null, + proposedLocation: null, + sourceMode: 'store', + enableAfterDeploy: false, + intendedActive: action === 'update', +} ); + +describe( 'edgeWorkersDeployCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.findWorker.mockImplementation( ( _projectDir, name ) => worker( name ) ); + project.discoverWorkers.mockReturnValue( [ worker( 'alpha' ), worker( 'beta' ) ] ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ planItem( 'headers' ) ] ); + deployment.deploymentPlanRows.mockReturnValue( [ { worker: 'headers' } ] ); + format.formatData.mockReturnValue( 'PLAN TABLE' ); + deployment.applyEdgeWorkerDeploymentPlan.mockResolvedValue(); + confirmation.isInteractiveEdgeWorkers.mockReturnValue( true ); + confirm.mockResolvedValue( true ); + } ); + + it( 'prepares the selected worker with the command options', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], opts ); + + expect( project.findWorker ).toHaveBeenCalledWith( '/project', 'headers' ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( { + appId: 1, + envId: 3, + projectDir: '/project', + workers: [ + expect.objectContaining( { manifest: expect.objectContaining( { name: 'headers' } ) } ), + ], + skipBuild: true, + skipValidate: false, + skipSource: false, + enableAfterDeploy: false, + } ); + } ); + + it( 'registers deploy enable as an explicit default-false option', () => { + expect( command.options ).toContainEqual( [ + 'enable', + 'Enable each deployed worker after a successful upload.', + false, + ] ); + } ); + + it( 'passes activation intent when preparing a named worker', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ); + + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( + expect.objectContaining( { enableAfterDeploy: true } ) + ); + } ); + + it( 'explains create and update source behavior in --skip-source help', () => { + expect( command.options ).toContainEqual( [ + 'skip-source', + 'Do not store source on create; preserve stored source on update.', + false, + ] ); + } ); + + it( 'prepares every discovered worker for --all', async () => { + await edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ); + + expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' ); + expect( project.findWorker ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).toHaveBeenCalledWith( + expect.objectContaining( { + workers: [ + expect.objectContaining( { manifest: expect.objectContaining( { name: 'alpha' } ) } ), + expect.objectContaining( { manifest: expect.objectContaining( { name: 'beta' } ) } ), + ], + enableAfterDeploy: true, + } ) + ); + } ); + + it( 'rejects a worker name combined with --all before project resolution', async () => { + await expect( edgeWorkersDeployCommand( [ 'headers' ], { ...opts, all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to deploy edge worker: Supply either a worker name or --all, not both.' + ); + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( deployment.prepareEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + } ); + + it( 'prepares, previews, confirms exact production targets, then applies the same items', async () => { + const plan = [ planItem( 'alpha' ), planItem( 'beta', 'update' ) ]; + const rows = [ { worker: 'alpha' }, { worker: 'beta' } ]; + const order = []; + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( plan ); + deployment.deploymentPlanRows.mockImplementation( received => { + expect( received ).toBe( plan ); + order.push( 'rows' ); + return rows; + } ); + format.formatData.mockImplementation( ( received, outputFormat ) => { + expect( received ).toBe( rows ); + expect( outputFormat ).toBe( 'table' ); + order.push( 'format' ); + return 'PLAN TABLE'; + } ); + console.log.mockImplementationOnce( value => { + expect( value ).toBe( 'PLAN TABLE' ); + order.push( 'preview' ); + } ); + confirm.mockImplementation( async message => { + expect( message ).toBe( + 'Deploy and enable 2 edge workers (alpha, beta) on example-app.production?' + ); + order.push( 'confirm' ); + return true; + } ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( async ( envId, received ) => { + expect( envId ).toBe( 3 ); + expect( received ).toBe( plan ); + order.push( 'apply' ); + } ); + + await edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ); + + expect( order ).toEqual( [ 'rows', 'format', 'preview', 'confirm', 'apply' ] ); + expect( confirmation.isInteractiveEdgeWorkers ).toHaveBeenCalledWith( { + ...opts, + all: true, + enable: true, + } ); + } ); + + it( 'previews and applies non-production deployments without prompting', async () => { + await edgeWorkersDeployCommand( [ 'headers' ], { + ...opts, + env: { id: 3, type: 'develop' }, + } ); + + expect( console.log ).toHaveBeenCalledWith( 'PLAN TABLE' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).toHaveBeenCalled(); + } ); + + it( 'previews but refuses non-interactive production before applying', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( edgeWorkersDeployCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).toHaveBeenCalledWith( 'PLAN TABLE' ); + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringMatching( /Refusing to deploy.*production/ ) + ); + } ); + + it( 'refuses non-interactive production deploy enable without the existing bypass', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( + edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Refusing to deploy and enable edge workers in production' ) + ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Pass --skip-confirmation' ) + ); + } ); + + it( 'allows explicit production confirmation bypass without prompting', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, skipConfirmation: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).toHaveBeenCalled(); + } ); + + it( 'prints success output only from the applied callback and then tracks success', async () => { + const item = planItem( 'headers', 'update' ); + item.worker.dir = '/private/customer/SENTINEL_SOURCE_PATH'; + item.input.source = 'SENTINEL_SOURCE_TEXT'; + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + active: true, + } ); + } + ); + + await edgeWorkersDeployCommand( [ 'headers' ], opts ); + + expect( console.log ).toHaveBeenCalledWith( + '✓ updated "headers"; remains active (7 bytes, phases: client_response)' + ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + { count: 1, enable: false, activeCount: 1 } + ); + const outputOrder = console.log.mock.invocationCallOrder.at( -1 ); + const telemetryOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( outputOrder ).toBeLessThan( telemetryOrder ); + const successTelemetry = tracker.trackEventWithEnv.mock.calls.find( + ( [ , , eventName ] ) => eventName === 'edge_workers_deploy_command_success' + ); + expect( JSON.stringify( successTelemetry ) ).not.toContain( 'SENTINEL_SOURCE_PATH' ); + expect( JSON.stringify( successTelemetry ) ).not.toContain( 'SENTINEL_SOURCE_TEXT' ); + } ); + + it( 'reports a create with enable from the final activated result', async () => { + const item = planItem( 'headers' ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + active: true, + } ); + } + ); + + await edgeWorkersDeployCommand( [ 'headers' ], { ...opts, enable: true } ); + + expect( console.log ).toHaveBeenCalledWith( + '✓ created "headers" and enabled it (7 bytes, phases: client_response)' + ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^Review created/ ) ); + } ); + + it( 'reports an upload-only create as inactive and prints one review follow-up', async () => { + const item = planItem( 'headers' ); + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( [ item ] ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + await onApplied( items[ 0 ], { + id: 42, + name: 'headers', + phases: [ 'client_response' ], + active: false, + } ); + } + ); + + await edgeWorkersDeployCommand( [ 'headers' ], opts ); + + expect( console.log ).toHaveBeenCalledWith( + '✓ created "headers"; inactive (7 bytes, phases: client_response)' + ); + expect( console.log ).toHaveBeenCalledWith( + 'Review created inactive edge worker "headers", then run `vip edge-workers enable ` when ready.' + ); + expect( + console.log.mock.calls.filter( ( [ message ] ) => + String( message ).startsWith( 'Review created' ) + ) + ).toHaveLength( 1 ); + } ); + + it( 'lists each upload-only --all create once in one review follow-up', async () => { + const plan = [ planItem( 'alpha' ), planItem( 'beta' ) ]; + deployment.prepareEdgeWorkerDeploymentPlan.mockResolvedValue( plan ); + deployment.applyEdgeWorkerDeploymentPlan.mockImplementation( + async ( _envId, items, onApplied ) => { + for ( const item of items ) { + await onApplied( item, { + id: item.worker.manifest.name === 'alpha' ? 1 : 2, + name: item.worker.manifest.name, + phases: [ 'client_response' ], + active: false, + } ); + } + } + ); + + await edgeWorkersDeployCommand( [], { ...opts, all: true } ); + + const guidance = console.log.mock.calls + .map( ( [ message ] ) => String( message ) ) + .find( message => message.startsWith( 'Review created' ) ); + expect( guidance ).toBe( + 'Review created inactive edge workers "alpha", "beta", then run `vip edge-workers enable ` for each one when ready.' + ); + expect( guidance?.match( /alpha/g ) ).toHaveLength( 1 ); + expect( guidance?.match( /beta/g ) ).toHaveLength( 1 ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + { count: 2, enable: false, activeCount: 0 } + ); + } ); + + it( 'does not preview or apply when preparation fails', async () => { + deployment.prepareEdgeWorkerDeploymentPlan.mockRejectedValue( + new Error( 'worker "beta" failed validation' ) + ); + + await expect( edgeWorkersDeployCommand( [], { ...opts, all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( deployment.deploymentPlanRows ).not.toHaveBeenCalled(); + expect( format.formatData ).not.toHaveBeenCalled(); + expect( deployment.applyEdgeWorkerDeploymentPlan ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to deploy edge worker: worker "beta" failed validation' + ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + } ); + + it( 'keeps preparation diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_DEPLOY_SECRET'; + const sourcePath = '/private/customer/project/workers/headers/assembly/index.ts'; + deployment.prepareEdgeWorkerDeploymentPlan.mockRejectedValue( + new Error( `Compiler printed ${ sourcePath }: ${ secret }\n\u001b[31merror` ) + ); + + await expect( edgeWorkersDeployCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_error', + { name: 'headers', error: 'deploy_failed' } + ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); + } ); + + it( 'reports exact progress and the original cause after a partial failure', async () => { + const cause = new Error( 'request timed out' ); + deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( + new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause, { + stage: 'upload', + uploadCompleted: false, + activeAfterUpload: null, + } ) + ); + + await expect( edgeWorkersDeployCommand( [], { ...opts, all: true } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Deployment stopped at "beta". Applied: alpha. Not applied: gamma. Cause: request timed out' + ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_deploy_command_success', + expect.anything() + ); + } ); + + it( 'reports enable-stage upload state as last confirmed and final state as unknown', async () => { + const cause = new Error( 'request timed out' ); + deployment.applyEdgeWorkerDeploymentPlan.mockRejectedValue( + new deployment.DeploymentApplyError( [ 'alpha' ], 'beta', [ 'gamma' ], cause, { + stage: 'enable', + uploadCompleted: true, + activeAfterUpload: false, + } ) + ); + + await expect( + edgeWorkersDeployCommand( [], { ...opts, all: true, enable: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Deployment uploaded "beta" and its last confirmed state was inactive, but the enable request failed. ' + + 'Final active state is unknown; verify with `vip edge-workers get beta` or `vip edge-workers list`. ' + + 'Completed: alpha. Not attempted: gamma. Cause: request timed out' + ); + expect( exit.withError ).not.toHaveBeenCalledWith( + expect.stringContaining( 'remains inactive' ) + ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^Review created/ ) ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-disable.js b/__tests__/bin/vip-edge-workers-disable.js new file mode 100644 index 000000000..2b9f413dd --- /dev/null +++ b/__tests__/bin/vip-edge-workers-disable.js @@ -0,0 +1,123 @@ +import { edgeWorkersDisableCommand } from '../../src/bin/vip-edge-workers-disable'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: 'mock-app-query', + findEdgeWorkerByName: jest.fn(), + setEdgeWorkerActive: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersDisableCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.setEdgeWorkerActive.mockResolvedValue( { ...worker, active: false } ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'does not prompt or mutate when the worker is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersDisableCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + expect.anything() + ); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.setEdgeWorkerActive.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersDisableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to disable edge worker: API unavailable' + ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_error', + { name: 'headers', error: 'disable_failed' } + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Disabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + expect.anything() + ); + } ); + + it( 'disables production immediately without prompting and reports success after the API', async () => { + await edgeWorkersDisableCommand( [ 'headers' ], opts ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'headers' ); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, false ); + expect( console.log ).toHaveBeenCalledWith( '✓ Disabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_disable_command_success', + { name: 'headers' } + ); + const mutationOrder = edgeWorkersApi.setEdgeWorkerActive.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( mutationOrder ).toBeLessThan( outputOrder ); + expect( mutationOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-enable.js b/__tests__/bin/vip-edge-workers-enable.js new file mode 100644 index 000000000..a8b0424c1 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-enable.js @@ -0,0 +1,184 @@ +import { edgeWorkersEnableCommand } from '../../src/bin/vip-edge-workers-enable'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as confirmation from '../../src/lib/edge-workers/confirmation'; +import { confirm } from '../../src/lib/envvar/input'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: 'mock-app-query', + findEdgeWorkerByName: jest.fn(), + setEdgeWorkerActive: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/confirmation', () => { + const actual = jest.requireActual( '../../src/lib/edge-workers/confirmation' ); + return { + ...actual, + isInteractiveEdgeWorkers: jest.fn(), + }; +} ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + skipConfirmation: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: null, + phases: [ 'client_response' ], + onFailure: 'continue', + active: false, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersEnableCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker ); + edgeWorkersApi.setEdgeWorkerActive.mockResolvedValue( { ...worker, active: true } ); + confirmation.isInteractiveEdgeWorkers.mockReturnValue( true ); + confirm.mockResolvedValue( true ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'does not prompt or mutate when the worker is not found', async () => { + edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null ); + + await expect( edgeWorkersEnableCommand( [ 'missing' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + expect.anything() + ); + } ); + + it( 'enables a non-production worker without prompting', async () => { + await edgeWorkersEnableCommand( [ 'headers' ], { + ...opts, + env: { id: 3, type: 'develop' }, + } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + } ); + + it( 'confirms the exact production worker before enabling it', async () => { + const order = []; + confirm.mockImplementation( async message => { + expect( message ).toBe( 'Enable edge worker "headers" on example-app.production?' ); + order.push( 'confirm' ); + return true; + } ); + edgeWorkersApi.setEdgeWorkerActive.mockImplementation( async () => { + order.push( 'mutation' ); + return { ...worker, active: true }; + } ); + + await edgeWorkersEnableCommand( [ 'headers' ], opts ); + + expect( order ).toEqual( [ 'confirm', 'mutation' ] ); + expect( confirmation.isInteractiveEdgeWorkers ).toHaveBeenCalledWith( opts ); + } ); + + it( 'refuses non-interactive production before enabling', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await expect( edgeWorkersEnableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringMatching( /Refusing to enable.*production/ ) + ); + } ); + + it( 'allows explicit production confirmation bypass', async () => { + confirmation.isInteractiveEdgeWorkers.mockReturnValue( false ); + + await edgeWorkersEnableCommand( [ 'headers' ], { ...opts, skipConfirmation: true } ); + + expect( confirm ).not.toHaveBeenCalled(); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + } ); + + it( 'reports API rejection without success output or telemetry', async () => { + edgeWorkersApi.setEdgeWorkerActive.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersEnableCommand( [ 'headers' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'Failed to enable edge worker: API unavailable' + ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_error', + { name: 'headers', error: 'enable_failed' } + ); + expect( console.log ).not.toHaveBeenCalledWith( '✓ Enabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + expect.anything() + ); + } ); + + it( 'prints and tracks success only after the confirmed API mutation succeeds', async () => { + await edgeWorkersEnableCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'headers' ); + expect( edgeWorkersApi.setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + expect( console.log ).toHaveBeenCalledWith( '✓ Enabled edge worker "headers".' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_enable_command_success', + { name: 'headers' } + ); + const mutationOrder = edgeWorkersApi.setEdgeWorkerActive.mock.invocationCallOrder[ 0 ]; + const outputOrder = console.log.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( mutationOrder ).toBeLessThan( outputOrder ); + expect( mutationOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-get.js b/__tests__/bin/vip-edge-workers-get.js new file mode 100644 index 000000000..ed397ab46 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-get.js @@ -0,0 +1,161 @@ +import { edgeWorkersGetCommand } from '../../src/bin/vip-edge-workers-get'; +import * as edgeWorkersApi from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: 'mock-app-query', + getEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { + app: { id: 1, name: 'example-app' }, + env: { id: 3, type: 'production' }, + source: false, +}; + +const worker = { + id: 7, + name: 'headers', + location: { operator: 'starts_with', value: '/api/' }, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', +}; + +describe( 'edgeWorkersGetCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + edgeWorkersApi.getEdgeWorker.mockResolvedValue( worker ); + tracker.trackEventWithEnv.mockResolvedValue(); + } ); + + it( 'requests default details without source and preserves key-value output', async () => { + await edgeWorkersGetCommand( [ 'headers' ], opts ); + + expect( edgeWorkersApi.getEdgeWorker ).toHaveBeenCalledWith( 1, 3, 'headers', { + includeSource: false, + } ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( '+ Name: headers' ) ); + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( '+ Location: starts_with "/api/"' ) + ); + expect( console.log ).not.toHaveBeenCalledWith( '\nSource:' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + { name: 'headers' } + ); + const apiOrder = edgeWorkersApi.getEdgeWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( apiOrder ).toBeLessThan( successOrder ); + } ); + + it( 'requests and prints stored source only for --source', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { + ...worker, + source: 'export default {};', + } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + expect( edgeWorkersApi.getEdgeWorker ).toHaveBeenCalledWith( 1, 3, 'headers', { + includeSource: true, + } ); + expect( console.log ).toHaveBeenCalledWith( '\nSource:' ); + expect( console.log ).toHaveBeenCalledWith( 'export default {};' ); + } ); + + it( 'reports when explicitly requested source was not stored', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { ...worker, source: null } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + expect( console.log ).toHaveBeenCalledWith( '(no source stored)' ); + } ); + + it( 'neutralizes terminal controls in remote details and stored source', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { + ...worker, + name: 'headers\u001b[2J', + location: { operator: 'starts_with', value: '/api/\u001b[31m' }, + phases: [ 'client_response\nforged' ], + onFailure: 'continue\u0007', + createdAt: '2026-08-18\rforged', + updatedAt: '2026-08-19\u009b31m', + source: 'export {};\n\u001b[2JSECRET', + } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + const output = console.log.mock.calls + .flat() + .filter( value => value !== '\nSource:' ) + .join( '|' ); + // eslint-disable-next-line no-control-regex + expect( output ).not.toMatch( /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/ ); + expect( output ).not.toContain( 'client_response\nforged' ); + expect( output ).toContain( String.raw`\u001b` ); + expect( output ).toContain( String.raw`\u000a` ); + } ); + + it( 'reports a missing worker without false success', async () => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( null ); + + await expect( edgeWorkersGetCommand( [ 'missing' ], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( + 'No edge worker named "missing" is deployed to this environment.' + ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + expect.anything() + ); + } ); + + it( 'reports API rejection without details or false success', async () => { + edgeWorkersApi.getEdgeWorker.mockRejectedValue( new Error( 'API unavailable' ) ); + + await expect( edgeWorkersGetCommand( [ 'headers' ], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( exit.withError ).toHaveBeenCalledWith( 'Failed to get edge worker: API unavailable' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_error', + { name: 'headers', error: 'get_failed' } + ); + expect( console.log ).not.toHaveBeenCalled(); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_get_command_success', + expect.anything() + ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js new file mode 100644 index 000000000..de17d51e9 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-init.js @@ -0,0 +1,95 @@ +import path from 'node:path'; + +import { edgeWorkersInitCommand } from '../../src/bin/vip-edge-workers-init'; +import * as exit from '../../src/lib/cli/exit'; +import * as toolchains from '../../src/lib/edge-workers/toolchains'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/edge-workers/toolchains', () => ( { + getToolchain: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'edgeWorkersInitCommand()', () => { + const scaffoldProject = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + toolchains.getToolchain.mockReturnValue( { scaffoldProject } ); + } ); + + it( 'scaffolds the requested project and prints next steps', async () => { + await edgeWorkersInitCommand( [ './infra/edge' ], { type: 'assemblyscript' } ); + + expect( scaffoldProject ).toHaveBeenCalledWith( + path.resolve( process.cwd(), 'infra', 'edge' ) + ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( 1, 'edge_workers_init_command_execute', { + type: 'assemblyscript', + } ); + expect( tracker.trackEvent ).toHaveBeenNthCalledWith( 2, 'edge_workers_init_command_success', { + type: 'assemblyscript', + } ); + expect( console.log ).toHaveBeenCalledWith( + expect.stringContaining( 'Created a new assemblyscript edge-workers project' ) + ); + const scaffoldOrder = scaffoldProject.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( scaffoldOrder ).toBeLessThan( successOrder ); + } ); + + it( 'reports an unsupported type without scaffolding or success telemetry', async () => { + await expect( edgeWorkersInitCommand( [], { type: 'rust' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( scaffoldProject ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_init_command_error', { + type: 'rust', + error: 'Unsupported type', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_init_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'reports a scaffold collision without success telemetry or output', async () => { + scaffoldProject.mockImplementation( () => { + throw new Error( 'target is not empty' ); + } ); + + await expect( edgeWorkersInitCommand( [], { type: 'assemblyscript' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_init_command_error', { + type: 'assemblyscript', + error: 'init_failed', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_init_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-list.js b/__tests__/bin/vip-edge-workers-list.js new file mode 100644 index 000000000..2af99918c --- /dev/null +++ b/__tests__/bin/vip-edge-workers-list.js @@ -0,0 +1,152 @@ +import { edgeWorkersListCommand } from '../../src/bin/vip-edge-workers-list'; +import * as api from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: '', + listEdgeWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { app: { id: 1 }, env: { id: 3 }, format: 'table' }; + +describe( 'edgeWorkersListCommand()', () => { + beforeEach( jest.clearAllMocks ); + + it( 'maps workers into flat, formattable rows', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers', + active: true, + phases: [ 'client_response' ], + location: { operator: 'starts_with', value: '/api/' }, + onFailure: 'continue', + updatedAt: '2026-06-04', + }, + ] ); + + const rows = await edgeWorkersListCommand( [], opts ); + + expect( rows ).toEqual( [ + { + id: 5, + name: 'headers', + active: 'yes', + phases: 'client_response', + location: 'starts_with "/api/"', + on_failure: 'continue', + modified: '2026-06-04', + }, + ] ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_success', + { count: 1 } + ); + const apiOrder = api.listEdgeWorkers.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( apiOrder ).toBeLessThan( successOrder ); + } ); + + it( 'shows a friendly message and returns an empty array when there are none', async () => { + api.listEdgeWorkers.mockResolvedValue( [] ); + + const rows = await edgeWorkersListCommand( [], opts ); + + expect( rows ).toEqual( [] ); + expect( console.log ).toHaveBeenCalledWith( + 'No edge workers are deployed to this environment.' + ); + } ); + + it( 'returns empty JSON data without friendly prose when there are no workers', async () => { + api.listEdgeWorkers.mockResolvedValue( [] ); + + const rows = await edgeWorkersListCommand( [], { ...opts, format: 'json' } ); + + expect( rows ).toEqual( [] ); + expect( console.log ).not.toHaveBeenCalled(); + } ); + + it( 'neutralizes terminal controls in remote table fields', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers\u001b[2J', + active: true, + phases: [ 'client_response\nforged' ], + location: { operator: 'starts_with', value: '/api/\u001b[31m' }, + onFailure: 'continue\u0007', + updatedAt: '2026-06-04\rforged', + }, + ] ); + + const [ row ] = await edgeWorkersListCommand( [], opts ); + const output = Object.values( row ).join( '|' ); + + // eslint-disable-next-line no-control-regex + expect( output ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( output ).toContain( String.raw`\u001b` ); + expect( output ).toContain( String.raw`\u000a` ); + } ); + + it( 'leaves JSON row values intact for JSON.stringify to escape', async () => { + api.listEdgeWorkers.mockResolvedValue( [ + { + id: 5, + name: 'headers\u001b[2J', + active: false, + phases: [], + location: null, + onFailure: 'continue', + updatedAt: '2026-06-04', + }, + ] ); + + const [ row ] = await edgeWorkersListCommand( [], { ...opts, format: 'json' } ); + + expect( row.name ).toBe( 'headers\u001b[2J' ); + expect( JSON.stringify( [ row ] ) ).toContain( String.raw`\u001b` ); + } ); + + it( 'reports a friendly error when the API call fails', async () => { + api.listEdgeWorkers.mockRejectedValue( new Error( 'boom' ) ); + + await expect( edgeWorkersListCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( 'Failed to list edge workers: boom' ); + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_error', + { error: 'list_failed' } + ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_list_command_success', + expect.anything() + ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-new.js b/__tests__/bin/vip-edge-workers-new.js new file mode 100644 index 000000000..c083dbb2d --- /dev/null +++ b/__tests__/bin/vip-edge-workers-new.js @@ -0,0 +1,133 @@ +import path from 'node:path'; + +import { edgeWorkersNewCommand } from '../../src/bin/vip-edge-workers-new'; +import * as exit from '../../src/lib/cli/exit'; +import * as project from '../../src/lib/edge-workers/project'; +import * as toolchains from '../../src/lib/edge-workers/toolchains'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + readProjectDescriptor: jest.fn(), + readWorkerManifest: jest.fn(), + resolveProjectDir: jest.fn(), + WORKERS_DIR: 'workers', + writeWorkerManifest: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/toolchains', () => ( { + getToolchain: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn(), +} ) ); + +describe( 'edgeWorkersNewCommand()', () => { + const scaffoldWorker = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + scaffoldWorker.mockReset(); + project.resolveProjectDir.mockReturnValue( '/project' ); + project.readProjectDescriptor.mockReturnValue( { type: 'assemblyscript' } ); + project.readWorkerManifest.mockReturnValue( { + name: 'demo', + entry: 'assembly/index.ts', + } ); + toolchains.getToolchain.mockReturnValue( { scaffoldWorker } ); + } ); + + it( 'rejects a non-portable name before resolving or modifying a project', async () => { + await expect( edgeWorkersNewCommand( [ 'bad/name' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( scaffoldWorker ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { + name: 'bad/name', + error: 'new_failed', + } ); + } ); + + it( 'writes and reports an explicit request scope', async () => { + await edgeWorkersNewCommand( [ 'demo' ], { location: 'starts_with:/api/' } ); + + expect( project.writeWorkerManifest ).toHaveBeenCalledWith( + path.join( '/project', 'workers', 'demo' ), + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/api/' }, + } + ); + expect( console.log ).toHaveBeenCalledWith( 'Scope: starts_with "/api/".' ); + } ); + + it( 'rejects an explicitly empty location before resolving or modifying a project', async () => { + await expect( edgeWorkersNewCommand( [ 'demo' ], { location: '' } ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( project.resolveProjectDir ).not.toHaveBeenCalled(); + expect( scaffoldWorker ).not.toHaveBeenCalled(); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { + name: 'demo', + error: 'new_failed', + } ); + } ); + + it( 'reports that an omitted location applies to all requests', async () => { + await edgeWorkersNewCommand( [ 'demo' ] ); + + expect( project.writeWorkerManifest ).not.toHaveBeenCalled(); + expect( console.log ).toHaveBeenCalledWith( + 'Scope: all requests. Set location in worker.json before deployment to narrow it.' + ); + } ); + + it( 'reports a toolchain failure without success telemetry or guidance', async () => { + scaffoldWorker.mockImplementation( () => { + throw new Error( 'scaffold failed' ); + } ); + + await expect( edgeWorkersNewCommand( [ 'demo' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_error', { + name: 'demo', + error: 'new_failed', + } ); + expect( tracker.trackEvent ).not.toHaveBeenCalledWith( + 'edge_workers_new_command_success', + expect.anything() + ); + expect( console.log ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'prints safe success guidance for a non-production environment', async () => { + await edgeWorkersNewCommand( [ 'demo' ] ); + + expect( scaffoldWorker ).toHaveBeenCalledWith( '/project', 'demo' ); + expect( console.log ).toHaveBeenCalledWith( ' vip @my-site.develop edge-workers deploy demo' ); + expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_new_command_success', { + name: 'demo', + type: 'assemblyscript', + } ); + const scaffoldOrder = scaffoldWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); + expect( scaffoldOrder ).toBeLessThan( successOrder ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js new file mode 100644 index 000000000..af57a5f51 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -0,0 +1,180 @@ +import { edgeWorkersValidateCommand } from '../../src/bin/vip-edge-workers-validate'; +import * as api from '../../src/lib/api/edge-workers'; +import * as exit from '../../src/lib/cli/exit'; +import * as lib from '../../src/lib/edge-workers'; +import * as project from '../../src/lib/edge-workers/project'; +import * as tracker from '../../src/lib/tracker'; + +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( exit, 'withError' ).mockImplementation( () => { + throw 'EXIT_WITH_ERROR'; +} ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/api/edge-workers', () => ( { + appQuery: '', + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers/project', () => ( { + resolveProjectDir: jest.fn(), + findWorker: jest.fn(), + discoverWorkers: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEventWithEnv: jest.fn(), +} ) ); + +const opts = { app: { id: 1 }, env: { id: 3 } }; + +const worker = { + dir: '/proj/workers/my-worker', + manifest: { name: 'my-worker', entry: 'assembly/index.ts' }, +}; + +describe( 'edgeWorkersValidateCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/proj' ); + project.findWorker.mockReturnValue( worker ); + lib.buildWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'V0FTTQ==', + } ); + api.validateEdgeWorker.mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'builds and validates the worker against the env', async () => { + await edgeWorkersValidateCommand( [ 'my-worker' ], opts ); + + expect( lib.buildWorker ).toHaveBeenCalledWith( '/proj', worker ); + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'V0FTTQ==' ); + expect( exit.withError ).not.toHaveBeenCalled(); + const validationOrder = api.validateEdgeWorker.mock.invocationCallOrder[ 0 ]; + const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 ); + expect( validationOrder ).toBeLessThan( successOrder ); + } ); + + it( 'uses the prebuilt artifact with --skip-build', async () => { + lib.readPrebuiltWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'UFJF', + } ); + + await edgeWorkersValidateCommand( [ 'my-worker' ], { ...opts, skipBuild: true } ); + + expect( lib.buildWorker ).not.toHaveBeenCalled(); + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'UFJF' ); + } ); + + it( 'exits with an error when a worker is invalid', async () => { + api.validateEdgeWorker.mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( 'failed validation' ) ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_success', + expect.anything() + ); + } ); + + it( 'does not report validation success when the API rejects', async () => { + api.validateEdgeWorker.mockRejectedValue( + new Error( 'validateEdgeWorker returned no result.' ) + ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( console.log ).not.toHaveBeenCalledWith( expect.stringContaining( 'is valid' ) ); + expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_success', + expect.anything() + ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'keeps compiler diagnostics local and out of analytics', async () => { + const secret = 'SENTINEL_VALIDATE_SECRET'; + const sourcePath = '/private/customer/project/workers/my-worker/assembly/index.ts'; + lib.buildWorker.mockImplementation( () => { + throw new Error( `Compiler printed ${ sourcePath }: ${ secret }\n\u001b[31merror` ); + } ); + + await expect( edgeWorkersValidateCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + + expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith( + 1, + 3, + 'edge_workers_validate_command_error', + { name: 'my-worker', error: 'validate_failed' } + ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( secret ); + expect( JSON.stringify( tracker.trackEventWithEnv.mock.calls ) ).not.toContain( sourcePath ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) ); + expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) ); + } ); + + it( 'validates every worker with --all', async () => { + project.discoverWorkers.mockReturnValue( [ + worker, + { dir: '/proj/workers/other', manifest: { name: 'other', entry: 'assembly/index.ts' } }, + ] ); + + await edgeWorkersValidateCommand( [], { ...opts, all: true } ); + + expect( api.validateEdgeWorker ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'errors when no worker name and no --all is given', async () => { + await expect( edgeWorkersValidateCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledTimes( 1 ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'supply a worker name' ) + ); + } ); + + it( 'rejects a worker name together with --all', async () => { + await expect( + edgeWorkersValidateCommand( [ 'my-worker' ], { ...opts, all: true } ) + ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'Supply either a worker name or --all, not both.' ) + ); + expect( project.discoverWorkers ).not.toHaveBeenCalled(); + expect( api.validateEdgeWorker ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/__tests__/lib/api-edge-workers.test.ts b/__tests__/lib/api-edge-workers.test.ts new file mode 100644 index 000000000..fb4b1efe2 --- /dev/null +++ b/__tests__/lib/api-edge-workers.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { print } from 'graphql'; + +import * as apiModule from '../../src/lib/api'; +import { + createEdgeWorker, + deleteEdgeWorker, + getEdgeWorker, + listEdgeWorkers, + setEdgeWorkerActive, + updateEdgeWorker, + validateEdgeWorker, +} from '../../src/lib/api/edge-workers'; +import UserError from '../../src/lib/user-error'; + +import type { DocumentNode } from 'graphql'; + +jest.mock( '../../src/lib/api' ); + +const mockMutate = + jest.fn< ( options: unknown ) => Promise< { data?: Record< string, unknown > } > >(); +const mockQuery = + jest.fn< + ( options: { query: DocumentNode } ) => Promise< { data?: Record< string, unknown > } > + >(); +const mockedAPI = apiModule as unknown as { default: jest.Mock }; + +beforeEach( () => { + mockMutate.mockReset(); + mockQuery.mockReset(); + mockedAPI.default = jest.fn().mockReturnValue( { mutate: mockMutate, query: mockQuery } ); +} ); + +describe( 'edge worker read query contracts', () => { + beforeEach( () => { + mockQuery.mockResolvedValue( { + data: { + app: { + environments: [ { id: 3, edgeWorkers: [ { id: 5, name: 'headers' } ] } ], + }, + }, + } ); + } ); + + it( 'omits source and wasmBinary from the default detail query', async () => { + await getEdgeWorker( 1, 3, 'headers' ); + + const queryDocument = mockQuery.mock.calls[ 0 ][ 0 ].query; + const query = print( queryDocument ); + + expect( query ).not.toContain( 'source' ); + expect( query ).not.toContain( 'wasmBinary' ); + } ); + + it( 'requests source but never wasmBinary when source is explicitly included', async () => { + await getEdgeWorker( 1, 3, 'headers', { includeSource: true } ); + + const queryDocument = mockQuery.mock.calls[ 0 ][ 0 ].query; + const query = print( queryDocument ); + + expect( query ).toContain( 'source' ); + expect( query ).not.toContain( 'wasmBinary' ); + } ); + + it( 'filters the environment server-side by id rather than client-side', async () => { + await listEdgeWorkers( 1, 3 ); + + const call = mockQuery.mock.calls[ 0 ][ 0 ] as { query: DocumentNode; variables?: unknown }; + const query = print( call.query ); + + expect( query ).toContain( 'environments(id: $envId)' ); + expect( call.variables ).toMatchObject( { appId: 1, envId: 3 } ); + } ); + + it( 'requests reads with exitOnError disabled so failures can be caught', async () => { + await listEdgeWorkers( 1, 3 ); + + expect( mockedAPI.default ).toHaveBeenCalledWith( { exitOnError: false } ); + } ); + + it.each( [ + [ 'missing data', undefined ], + [ 'null app', { app: null } ], + [ 'non-object app', { app: 'not-an-app' } ], + [ 'missing environments', { app: {} } ], + [ 'null environments', { app: { environments: null } } ], + [ 'non-array environments', { app: { environments: {} } } ], + [ 'malformed environment', { app: { environments: [ null ] } } ], + [ 'empty environments (target not found)', { app: { environments: [] } } ], + [ 'missing edgeWorkers', { app: { environments: [ { id: 3 } ] } } ], + [ 'null edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: null } ] } } ], + [ 'non-array edgeWorkers', { app: { environments: [ { id: 3, edgeWorkers: {} } ] } } ], + ] )( 'fails closed for %s', async ( _label, data ) => { + mockQuery.mockResolvedValueOnce( { data: data as never } ); + + const read = listEdgeWorkers( 1, 3 ); + + await expect( read ).rejects.toBeInstanceOf( UserError ); + await expect( read ).rejects.toThrow( /EdgeWorkers query returned an invalid response/ ); + } ); + + it( 'preserves a legitimate empty edgeWorkers array', async () => { + mockQuery.mockResolvedValueOnce( { + data: { app: { environments: [ { id: 3, edgeWorkers: [] } ] } }, + } ); + + await expect( listEdgeWorkers( 1, 3 ) ).resolves.toEqual( [] ); + } ); +} ); + +describe( 'edge worker mutation result contracts', () => { + it.each( [ + [ 'validateEdgeWorker', () => validateEdgeWorker( 3, 'V0FTTQ==' ) ], + [ 'createEdgeWorker', () => createEdgeWorker( 3, { name: 'demo', wasmBinary: 'V0FTTQ==' } ) ], + [ 'updateEdgeWorker', () => updateEdgeWorker( 3, 7, { wasmBinary: 'V0FTTQ==' } ) ], + [ 'setEdgeWorkerActive', () => setEdgeWorkerActive( 3, 7, true ) ], + ] )( 'rejects a missing %s payload', async ( operation, call ) => { + mockMutate.mockResolvedValueOnce( { data: { [ operation ]: null } } ); + + await expect( call() ).rejects.toThrow( `${ operation } returned no result` ); + } ); + + it( 'rejects a false delete result', async () => { + mockMutate.mockResolvedValueOnce( { data: { deleteEdgeWorker: false } } ); + + await expect( deleteEdgeWorker( 3, 7 ) ).rejects.toThrow( /did not confirm deletion/ ); + } ); + + it.each( [ + [ 'validateEdgeWorker', () => validateEdgeWorker( 3, 'V0FTTQ==' ) ], + [ 'createEdgeWorker', () => createEdgeWorker( 3, { name: 'demo', wasmBinary: 'V0FTTQ==' } ) ], + [ 'updateEdgeWorker', () => updateEdgeWorker( 3, 7, { wasmBinary: 'V0FTTQ==' } ) ], + [ 'setEdgeWorkerActive', () => setEdgeWorkerActive( 3, 7, true ) ], + [ 'deleteEdgeWorker', () => deleteEdgeWorker( 3, 7 ) ], + ] )( 'requests %s with exitOnError disabled so failures can be caught', async ( key, call ) => { + mockMutate.mockResolvedValueOnce( { + data: { [ key ]: key === 'deleteEdgeWorker' ? true : { id: 7 } }, + } ); + + await call(); + + expect( mockedAPI.default ).toHaveBeenCalledWith( { exitOnError: false } ); + } ); +} ); diff --git a/__tests__/lib/api-error-debug.test.ts b/__tests__/lib/api-error-debug.test.ts new file mode 100644 index 000000000..a9263d689 --- /dev/null +++ b/__tests__/lib/api-error-debug.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from '@jest/globals'; + +import { safeGraphQLErrorDebugInfo } from '../../src/lib/api/error-debug'; + +describe( 'safeGraphQLErrorDebugInfo()', () => { + it( 'returns only allowlisted operation, path, and string code metadata', () => { + const error = { + message: 'field failed', + path: [ 'app', 'environments', 0, 'envVars' ], + extensions: { code: 'FORBIDDEN', secret: 'must-not-appear' }, + }; + + const info = safeGraphQLErrorDebugInfo( 'EnvVars', [ error ] ); + + expect( info ).toEqual( [ + { + operation: 'EnvVars', + path: [ 'app', 'environments', 0, 'envVars' ], + code: 'FORBIDDEN', + }, + ] ); + expect( JSON.stringify( info ) ).not.toContain( 'field failed' ); + expect( JSON.stringify( info ) ).not.toContain( 'must-not-appear' ); + } ); + + it( 'omits non-string codes and defaults a missing path to an empty array', () => { + const error = { + message: 'binary rejected', + extensions: { code: 403, source: 'sensitive source' }, + }; + const info = safeGraphQLErrorDebugInfo( 'EdgeWorkerDetail', [ error ] ); + + expect( info ).toEqual( [ { operation: 'EdgeWorkerDetail', path: [] } ] ); + expect( JSON.stringify( info ) ).not.toContain( 'binary rejected' ); + expect( JSON.stringify( info ) ).not.toContain( 'sensitive source' ); + } ); +} ); diff --git a/__tests__/lib/cli/command.js b/__tests__/lib/cli/command.js index 2a42541e5..e4c58e9fd 100644 --- a/__tests__/lib/cli/command.js +++ b/__tests__/lib/cli/command.js @@ -197,6 +197,17 @@ describe( 'utils/cli/command', () => { } ); describe( 'option parsing', () => { + it( 'prints valid empty JSON from the real formatted command wrapper', async () => { + const cmd = command( { format: true, requiredArgs: 0 } ); + + await cmd.argv( + [ process.execPath, '/path/to/vip-edge-workers-list.js', '--format=json' ], + async () => [] + ); + + expect( console.log ).toHaveBeenCalledWith( '[]' ); + } ); + it( 'does not duplicate defaults when an explicit value matches the default', async () => { const cmd = command( { requiredArgs: 0 } ).option( 'type', 'Log type', 'app' ); diff --git a/__tests__/lib/cli/format.js b/__tests__/lib/cli/format.js index 5fd8d8d06..34e014621 100644 --- a/__tests__/lib/cli/format.js +++ b/__tests__/lib/cli/format.js @@ -1,6 +1,26 @@ -import { formatBytes, formatDuration, requoteArgs, table } from '../../../src/lib/cli/format'; +import { + formatBytes, + formatData, + formatDuration, + requoteArgs, + table, +} from '../../../src/lib/cli/format'; describe( 'utils/cli/format', () => { + it( 'formats an empty JSON collection as a valid array', () => { + expect( formatData( [], 'json' ) ).toBe( '[]' ); + } ); + + it( 'JSON-escapes terminal controls without changing parsed data', () => { + const data = [ { value: 'safe\u007f\u009b[31m' } ]; + + const formatted = formatData( data, 'json' ); + + expect( formatted ).not.toMatch( /[\u007f-\u009f]/ ); + expect( formatted ).toContain( String.raw`\u007f\u009b` ); + expect( JSON.parse( formatted ) ).toEqual( data ); + } ); + describe( 'requoteArgs', () => { it.each( [ { diff --git a/__tests__/lib/edge-workers/confirmation.test.ts b/__tests__/lib/edge-workers/confirmation.test.ts new file mode 100644 index 000000000..b49254f63 --- /dev/null +++ b/__tests__/lib/edge-workers/confirmation.test.ts @@ -0,0 +1,284 @@ +import { + confirmEdgeWorkerDeletion, + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../../../src/lib/edge-workers/confirmation'; +import UserError from '../../../src/lib/user-error'; + +const productionRequest = { + action: 'deploy' as const, + appName: 'example-app', + envType: 'production', + workerNames: [ 'headers', 'redirects' ], + enableAfterDeploy: false, + skipConfirmation: false, + nonInteractive: false, +}; + +function withStdoutIsTTY< T >( isTTY: boolean, callback: () => T ): T { + const originalDescriptor = Object.getOwnPropertyDescriptor( process, 'stdout' ); + const stdout = Object.create( process.stdout ) as NodeJS.WriteStream; + Object.defineProperty( stdout, 'isTTY', { + configurable: true, + value: isTTY, + writable: true, + } ); + + try { + Object.defineProperty( process, 'stdout', { + configurable: true, + enumerable: originalDescriptor?.enumerable ?? true, + value: stdout, + writable: true, + } ); + return callback(); + } finally { + if ( originalDescriptor ) { + Object.defineProperty( process, 'stdout', originalDescriptor ); + } else { + delete ( process as { stdout?: NodeJS.WriteStream } ).stdout; + } + } +} + +describe( 'isInteractiveEdgeWorkers()', () => { + it( 'uses a TTY when no non-interactive override is set', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( true ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats VIP_NON_INTERACTIVE=1 as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + process.env.VIP_NON_INTERACTIVE = '1'; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats an explicit nonInteractive option as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( true, () => { + expect( isInteractiveEdgeWorkers( { nonInteractive: true } ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); + + it( 'treats non-TTY stdout as non-interactive', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + + try { + delete process.env.VIP_NON_INTERACTIVE; + withStdoutIsTTY( false, () => { + expect( isInteractiveEdgeWorkers( {} ) ).toBe( false ); + } ); + } finally { + if ( originalEnv === undefined ) { + delete process.env.VIP_NON_INTERACTIVE; + } else { + process.env.VIP_NON_INTERACTIVE = originalEnv; + } + } + } ); +} ); + +describe( 'confirmProductionEdgeWorkerMutation()', () => { + it( 'prompts with every exact worker name for interactive production deploys', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( productionRequest, confirmFn ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Deploy 2 edge workers (headers, redirects) to example-app.production?' + ); + } ); + + it( 'prompts once for upload and activation when deploy enable is requested', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, enableAfterDeploy: true }, + confirmFn + ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Deploy and enable 2 edge workers (headers, redirects) on example-app.production?' + ); + expect( confirmFn ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'prompts with the exact worker identity for interactive production enables', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, action: 'enable', workerNames: [ 'headers' ] }, + confirmFn + ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Enable edge worker "headers" on example-app.production?' + ); + } ); + + it( 'neutralizes terminal controls in production confirmation identities', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmProductionEdgeWorkerMutation( + { + ...productionRequest, + appName: 'example\u001b[2J', + envType: 'production', + workerNames: [ 'headers\nforged' ], + }, + confirmFn + ); + + const message = confirmFn.mock.calls[ 0 ][ 0 ]; + // eslint-disable-next-line no-control-regex + expect( message ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( message ).toContain( String.raw`\u001b` ); + expect( message ).toContain( String.raw`\u000a` ); + } ); + + it( 'rejects non-interactive production without bypass', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await expect( + confirmProductionEdgeWorkerMutation( + { ...productionRequest, nonInteractive: true }, + confirmFn + ) + ).rejects.toThrow( /Refusing to deploy.*production/ ); + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + + it( 'rejects non-interactive production activation with only the existing bypass guidance', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await expect( + confirmProductionEdgeWorkerMutation( + { ...productionRequest, enableAfterDeploy: true, nonInteractive: true }, + confirmFn + ) + ).rejects.toEqual( + new UserError( + 'Refusing to deploy and enable edge workers in production without confirmation. ' + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + + it( 'throws UserError when the user declines', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); + + await expect( + confirmProductionEdgeWorkerMutation( productionRequest, confirmFn ) + ).rejects.toEqual( new UserError( 'Command cancelled by user.' ) ); + } ); + + it( 'skips prompting when bypassed', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, skipConfirmation: true, nonInteractive: true }, + confirmFn + ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); + + it( 'does not prompt outside production', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmProductionEdgeWorkerMutation( + { ...productionRequest, envType: 'develop', nonInteractive: true }, + confirmFn + ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'confirmEdgeWorkerDeletion()', () => { + const request = { + appName: 'example-app', + envType: 'production', + workerName: 'headers', + force: false, + }; + + it( 'prompts with the exact destructive target', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmEdgeWorkerDeletion( request, confirmFn ); + + expect( confirmFn ).toHaveBeenCalledWith( + 'Permanently delete edge worker "headers" from example-app.production?' + ); + } ); + + it( 'neutralizes terminal controls in deletion confirmation identities', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( true ); + + await confirmEdgeWorkerDeletion( + { + ...request, + appName: 'example\u001b[2J', + workerName: 'headers\nforged', + }, + confirmFn + ); + + const message = confirmFn.mock.calls[ 0 ][ 0 ]; + // eslint-disable-next-line no-control-regex + expect( message ).not.toMatch( /[\u0000-\u001f\u007f-\u009f]/ ); + expect( message ).toContain( String.raw`\u001b` ); + expect( message ).toContain( String.raw`\u000a` ); + } ); + + it( 'throws UserError when the user declines deletion', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >().mockResolvedValue( false ); + + await expect( confirmEdgeWorkerDeletion( request, confirmFn ) ).rejects.toEqual( + new UserError( 'Command cancelled by user.' ) + ); + } ); + + it( 'does not prompt when force is set', async () => { + const confirmFn = jest.fn< Promise< boolean >, [ string ] >(); + + await confirmEdgeWorkerDeletion( { ...request, force: true }, confirmFn ); + + expect( confirmFn ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/__tests__/lib/edge-workers/deployment.test.ts b/__tests__/lib/edge-workers/deployment.test.ts new file mode 100644 index 000000000..5911d98ae --- /dev/null +++ b/__tests__/lib/edge-workers/deployment.test.ts @@ -0,0 +1,600 @@ +import { + createEdgeWorker, + listEdgeWorkers, + setEdgeWorkerActive, + updateEdgeWorker, + validateEdgeWorker, +} from '../../../src/lib/api/edge-workers'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from '../../../src/lib/edge-workers'; +import { + applyEdgeWorkerDeploymentPlan, + DeploymentApplyError, + deploymentPlanRows, + prepareEdgeWorkerDeploymentPlan, +} from '../../../src/lib/edge-workers/deployment'; + +import type { EdgeWorker } from '../../../src/lib/edge-workers/types'; + +jest.mock( '../../../src/lib/api/edge-workers', () => ( { + createEdgeWorker: jest.fn(), + listEdgeWorkers: jest.fn(), + setEdgeWorkerActive: jest.fn(), + updateEdgeWorker: jest.fn(), + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), + readWorkerSource: jest.fn(), +} ) ); + +const location = { operator: 'starts_with' as const, value: '/api/' }; +const replacementLocation = { operator: 'equals' as const, value: '/checkout' }; + +const remoteWorker = ( overrides: Partial< EdgeWorker > = {} ): EdgeWorker => ( { + id: 42, + name: 'headers', + location, + phases: [ 'client_response' ], + onFailure: 'continue', + active: true, + createdAt: '2026-08-18T00:00:00Z', + updatedAt: '2026-08-18T00:00:00Z', + ...overrides, +} ); + +const localWorker = ( manifest: Record< string, unknown > = {} ) => ( { + dir: '/project/workers/headers', + manifest: { + name: 'headers', + entry: 'assembly/index.ts', + on_failure: 'continue' as const, + ...manifest, + }, +} ); + +const options = ( workers = [ localWorker() ] ) => ( { + appId: 1, + envId: 3, + projectDir: '/project', + workers, + skipBuild: false, + skipValidate: false, + skipSource: false, + enableAfterDeploy: false, +} ); + +describe( 'prepareEdgeWorkerDeploymentPlan()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + jest.mocked( buildWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + jest.mocked( readWorkerSource ).mockReturnValue( 'source code' ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'preserves an existing location when the update manifest omits location', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( options() ); + + expect( plan[ 0 ].action ).toBe( 'update' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'location' ); + expect( plan[ 0 ].currentLocation ).toEqual( location ); + expect( plan[ 0 ].proposedLocation ).toEqual( location ); + } ); + + it( 'clears an existing location when the update manifest sets location to null', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: null } ) ] ) + ); + + expect( plan[ 0 ].input ).toEqual( + expect.objectContaining( { + location: null, + } ) + ); + expect( plan[ 0 ].currentLocation ).toEqual( location ); + expect( plan[ 0 ].proposedLocation ).toBeNull(); + } ); + + it( 'replaces an existing location when the update manifest supplies an object', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: replacementLocation } ) ] ) + ); + + expect( plan[ 0 ].input ).toEqual( + expect.objectContaining( { + location: replacementLocation, + } ) + ); + expect( plan[ 0 ].proposedLocation ).toEqual( replacementLocation ); + } ); + + it.each( [ + [ 'absent', {} ], + [ 'null', { location: null } ], + ] )( 'treats a %s location as all requests on create', async ( _label, manifest ) => { + const plan = await prepareEdgeWorkerDeploymentPlan( options( [ localWorker( manifest ) ] ) ); + + expect( plan[ 0 ].action ).toBe( 'create' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'location' ); + expect( plan[ 0 ].currentLocation ).toBeNull(); + expect( plan[ 0 ].proposedLocation ).toBeNull(); + } ); + + it( 'omits source on create when source storage is skipped', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( plan[ 0 ].sourceMode ).toBe( 'omit' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'source' ); + } ); + + it( 'preserves stored source on update when source storage is skipped', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( plan[ 0 ].sourceMode ).toBe( 'preserve' ); + expect( plan[ 0 ].input ).not.toHaveProperty( 'source' ); + } ); + + it( 'stores an empty source file on update when source storage is enabled', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + jest.mocked( readWorkerSource ).mockReturnValue( '' ); + + const plan = await prepareEdgeWorkerDeploymentPlan( options() ); + + expect( plan[ 0 ].sourceMode ).toBe( 'store' ); + expect( plan[ 0 ].input ).toHaveProperty( 'source', '' ); + } ); + + it( 'aborts preparation when source cannot be read', async () => { + jest.mocked( readWorkerSource ).mockImplementation( () => { + throw new Error( 'Could not read worker source.' ); + } ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + 'Could not read worker source.' + ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'requires explicit successful validation and never applies during preparation', async () => { + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + 'worker "headers" failed validation: missing alloc export' + ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'loads remote workers once and prepares every selected worker', async () => { + const secondWorker = localWorker( { name: 'redirects' } ); + jest + .mocked( buildWorker ) + .mockReturnValueOnce( { + wasmPath: '/project/build/headers.wasm', + base64: 'SEVBREVSUw==', + sizeBytes: 7, + } ) + .mockReturnValueOnce( { + wasmPath: '/project/build/redirects.wasm', + base64: 'UkVESVJFQ1RT', + sizeBytes: 9, + } ); + + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker(), secondWorker ] ) + ); + + expect( listEdgeWorkers ).toHaveBeenCalledTimes( 1 ); + expect( listEdgeWorkers ).toHaveBeenCalledWith( 1, 3 ); + expect( plan.map( item => item.worker.manifest.name ) ).toEqual( [ 'headers', 'redirects' ] ); + expect( buildWorker ).toHaveBeenCalledTimes( 2 ); + expect( validateEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( readWorkerSource ).toHaveBeenCalledTimes( 2 ); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'does no preparation or mutation when the remote read state is malformed', async () => { + jest + .mocked( listEdgeWorkers ) + .mockRejectedValue( new Error( 'EdgeWorkers query returned an invalid response.' ) ); + + await expect( prepareEdgeWorkerDeploymentPlan( options() ) ).rejects.toThrow( + /invalid response/ + ); + expect( buildWorker ).not.toHaveBeenCalled(); + expect( readPrebuiltWorker ).not.toHaveBeenCalled(); + expect( validateEdgeWorker ).not.toHaveBeenCalled(); + expect( readWorkerSource ).not.toHaveBeenCalled(); + expect( createEdgeWorker ).not.toHaveBeenCalled(); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'uses prebuilt artifacts and records skipped validation when requested', async () => { + jest.mocked( readPrebuiltWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipBuild: true, + skipValidate: true, + } ); + + expect( buildWorker ).not.toHaveBeenCalled(); + expect( readPrebuiltWorker ).toHaveBeenCalled(); + expect( validateEdgeWorker ).not.toHaveBeenCalled(); + expect( plan[ 0 ].validation ).toBe( 'skipped' ); + expect( plan[ 0 ].phases ).toEqual( [] ); + } ); +} ); + +describe( 'deploymentPlanRows()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker() ] ); + jest.mocked( buildWorker ).mockReturnValue( { + wasmPath: '/project/build/headers.wasm', + base64: 'V0FTTQ==', + sizeBytes: 128, + } ); + jest.mocked( readWorkerSource ).mockReturnValue( 'source code' ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'renders a stable row from the exact prepared update item', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( + options( [ localWorker( { location: null } ) ] ) + ); + + expect( deploymentPlanRows( plan ) ).toEqual( [ + { + worker: 'headers', + action: 'update', + current_active: 'active', + final_active: 'active', + current_scope: 'starts_with "/api/"', + proposed_scope: 'all requests', + validation: 'passed', + phases: 'client_response', + bytes: '128', + source: 'store', + }, + ] ); + } ); + + it( 'renders create defaults and all validated phases without mutating the plan', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_request', 'origin_request' ], + errors: [], + } ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + skipSource: true, + } ); + const itemBeforePreview = { ...plan[ 0 ] }; + + expect( deploymentPlanRows( plan ) ).toEqual( [ + { + worker: 'headers', + action: 'create', + current_active: 'new', + final_active: 'inactive', + current_scope: 'all requests', + proposed_scope: 'all requests', + validation: 'passed', + phases: 'client_request, origin_request', + bytes: '128', + source: 'omit', + }, + ] ); + expect( plan[ 0 ] ).toMatchObject( { + enableAfterDeploy: false, + intendedActive: false, + } ); + expect( plan[ 0 ] ).toEqual( itemBeforePreview ); + } ); + + it( 'renders an active final state for a create when enable is requested', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'new', + final_active: 'active', + } ), + ] ); + } ); + + it( 'renders an active final state for an inactive update when enable is requested', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ remoteWorker( { active: false } ) ] ); + + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'inactive', + final_active: 'active', + } ), + ] ); + } ); + + it( 'keeps an already-active update active when enable is requested', async () => { + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const rows = deploymentPlanRows( plan ); + + expect( plan[ 0 ] ).toMatchObject( { enableAfterDeploy: true, intendedActive: true } ); + expect( rows ).toEqual( [ + expect.objectContaining( { + current_active: 'active', + final_active: 'active', + } ), + ] ); + } ); + + it( 'neutralizes terminal controls in remote preview fields', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ + remoteWorker( { + location: { operator: 'starts_with', value: '/api/\u001b[2J' }, + } ), + ] ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response\u001b[31m' as never ], + errors: [], + } ); + + const [ row ] = deploymentPlanRows( await prepareEdgeWorkerDeploymentPlan( options() ) ); + const rendered = Object.values( row ).join( '|' ); + + expect( rendered ).not.toContain( '\u001b' ); + expect( rendered ).toContain( String.raw`\u001b` ); + } ); +} ); + +describe( 'applyEdgeWorkerDeploymentPlan()', () => { + beforeEach( () => { + jest.clearAllMocks(); + jest.mocked( buildWorker ).mockImplementation( ( _projectDir, worker ) => ( { + wasmPath: `/project/build/${ worker.manifest.name }.wasm`, + base64: `binary-${ worker.manifest.name }`, + sizeBytes: worker.manifest.name.length, + } ) ); + jest + .mocked( readWorkerSource ) + .mockImplementation( worker => `source-${ worker.manifest.name }` ); + jest.mocked( validateEdgeWorker ).mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'applies create and update items sequentially and reports each resolved result', async () => { + const redirects = localWorker( { name: 'redirects', location: null } ); + const existingRedirects = remoteWorker( { id: 84, name: 'redirects', active: false } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existingRedirects ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( options( [ localWorker(), redirects ] ) ); + const created = remoteWorker( { id: 7, location: null, active: false } ); + const updated = remoteWorker( { id: 84, name: 'redirects', location: null, active: false } ); + const order: string[] = []; + jest.mocked( createEdgeWorker ).mockImplementation( () => { + order.push( 'create' ); + return Promise.resolve( created ); + } ); + jest.mocked( updateEdgeWorker ).mockImplementation( () => { + order.push( 'update' ); + return Promise.resolve( updated ); + } ); + const planBeforeApply = plan.map( item => ( { ...item, input: { ...item.input } } ) ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, ( item, result ) => { + order.push( `applied:${ item.worker.manifest.name }:${ result.id }` ); + } ); + + expect( createEdgeWorker ).toHaveBeenCalledWith( 3, plan[ 0 ].input ); + expect( updateEdgeWorker ).toHaveBeenCalledWith( 3, 84, plan[ 1 ].input ); + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( order ).toEqual( [ 'create', 'applied:headers:7', 'update', 'applied:redirects:84' ] ); + expect( plan ).toEqual( planBeforeApply ); + } ); + + it( 'enables an inactive create after upload and reports the activated worker', async () => { + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { id: 7, location: null, active: false } ); + const enabled = remoteWorker( { id: 7, location: null, active: true } ); + const order: string[] = []; + jest.mocked( createEdgeWorker ).mockImplementation( () => { + order.push( 'create' ); + return Promise.resolve( uploaded ); + } ); + jest.mocked( setEdgeWorkerActive ).mockImplementation( () => { + order.push( 'enable' ); + return Promise.resolve( enabled ); + } ); + const onApplied = jest.fn( ( _item: unknown, result: EdgeWorker ) => { + order.push( `applied:headers:${ result.active }` ); + } ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 7, true ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], enabled ); + expect( order ).toEqual( [ 'create', 'enable', 'applied:headers:true' ] ); + } ); + + it( 'enables an inactive update after upload and reports the activated worker', async () => { + const existing = remoteWorker( { active: false } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existing ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { active: false } ); + const enabled = remoteWorker( { active: true } ); + const order: string[] = []; + jest.mocked( updateEdgeWorker ).mockImplementation( () => { + order.push( 'update' ); + return Promise.resolve( uploaded ); + } ); + jest.mocked( setEdgeWorkerActive ).mockImplementation( () => { + order.push( 'enable' ); + return Promise.resolve( enabled ); + } ); + const onApplied = jest.fn( ( _item: unknown, result: EdgeWorker ) => { + order.push( `applied:headers:${ result.active }` ); + } ); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).toHaveBeenCalledWith( 3, 42, true ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], enabled ); + expect( order ).toEqual( [ 'update', 'enable', 'applied:headers:true' ] ); + } ); + + it( 'does not redundantly enable an update that remains active after upload', async () => { + const existing = remoteWorker( { active: true } ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [ existing ] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options(), + enableAfterDeploy: true, + } ); + const uploaded = remoteWorker( { active: true } ); + jest.mocked( updateEdgeWorker ).mockResolvedValue( uploaded ); + const onApplied = jest.fn(); + + await applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], uploaded ); + } ); + + it( 'reports applied, failed, and unapplied names without retry or rollback', async () => { + const workers = [ 'alpha', 'beta', 'gamma' ].map( name => localWorker( { name } ) ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( options( workers ) ); + const cause = new Error( 'request timed out' ); + jest + .mocked( createEdgeWorker ) + .mockResolvedValueOnce( remoteWorker( { id: 1, name: 'alpha', active: false } ) ) + .mockRejectedValueOnce( cause ); + const onApplied = jest.fn(); + + const application = applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + await expect( application ).rejects.toBeInstanceOf( DeploymentApplyError ); + await expect( application ).rejects.toMatchObject( { + stage: 'upload', + appliedNames: [ 'alpha' ], + failedName: 'beta', + unappliedNames: [ 'gamma' ], + uploadCompleted: false, + activeAfterUpload: null, + cause, + } ); + expect( createEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( updateEdgeWorker ).not.toHaveBeenCalled(); + expect( setEdgeWorkerActive ).not.toHaveBeenCalled(); + expect( onApplied ).toHaveBeenCalledTimes( 1 ); + expect( onApplied ).toHaveBeenCalledWith( + plan[ 0 ], + expect.objectContaining( { name: 'alpha' } ) + ); + } ); + + it( 'reports an ambiguous enable failure after upload and stops later workers', async () => { + const workers = [ 'alpha', 'beta', 'gamma' ].map( name => localWorker( { name } ) ); + jest.mocked( listEdgeWorkers ).mockResolvedValue( [] ); + const plan = await prepareEdgeWorkerDeploymentPlan( { + ...options( workers ), + enableAfterDeploy: true, + } ); + const alphaUploaded = remoteWorker( { id: 1, name: 'alpha', active: false } ); + const alphaEnabled = remoteWorker( { id: 1, name: 'alpha', active: true } ); + const betaUploaded = remoteWorker( { id: 2, name: 'beta', active: false } ); + const cause = new Error( 'request timed out' ); + jest + .mocked( createEdgeWorker ) + .mockResolvedValueOnce( alphaUploaded ) + .mockResolvedValueOnce( betaUploaded ); + jest + .mocked( setEdgeWorkerActive ) + .mockResolvedValueOnce( alphaEnabled ) + .mockRejectedValueOnce( cause ); + const onApplied = jest.fn(); + + const application = applyEdgeWorkerDeploymentPlan( 3, plan, onApplied ); + + await expect( application ).rejects.toMatchObject( { + stage: 'enable', + appliedNames: [ 'alpha' ], + failedName: 'beta', + unappliedNames: [ 'gamma' ], + uploadCompleted: true, + activeAfterUpload: false, + cause, + } ); + expect( createEdgeWorker ).toHaveBeenCalledTimes( 2 ); + expect( setEdgeWorkerActive ).toHaveBeenCalledTimes( 2 ); + expect( onApplied ).toHaveBeenCalledTimes( 1 ); + expect( onApplied ).toHaveBeenCalledWith( plan[ 0 ], alphaEnabled ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/location.js b/__tests__/lib/edge-workers/location.js new file mode 100644 index 000000000..08d1a4493 --- /dev/null +++ b/__tests__/lib/edge-workers/location.js @@ -0,0 +1,34 @@ +import { parseLocationOption } from '../../../src/lib/edge-workers/location'; + +describe( 'parseLocationOption()', () => { + it.each( [ + [ 'starts_with:/api/', { operator: 'starts_with', value: '/api/' } ], + [ 'equals:/feed', { operator: 'equals', value: '/feed' } ], + [ 'ends_with:.json', { operator: 'ends_with', value: '.json' } ], + [ 'contains:preview', { operator: 'contains', value: 'preview' } ], + // Only the first colon separates the operator; the value keeps the rest. + [ 'equals:/api/v1:beta', { operator: 'equals', value: '/api/v1:beta' } ], + ] )( 'parses %s', ( raw, expected ) => { + expect( parseLocationOption( raw ) ).toEqual( expected ); + } ); + + it.each( [ 'starts_with', 'starts_with:', 'matches:/api/', ':/api/', '/api/', '' ] )( + 'rejects %s', + raw => { + expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); + } + ); + + it.each( [ 'starts_with:/api/\u001b[2J', 'equals:/safe\nforged output' ] )( + 'rejects control characters in %p', + raw => { + expect( () => parseLocationOption( raw ) ).toThrow( 'Invalid location' ); + } + ); + + it( 'rejects a --location flag passed without a value', () => { + // A value-less `--location` arrives as boolean true from the arg parser; + // guard so it does not throw a TypeError from `.indexOf()`. + expect( () => parseLocationOption( true ) ).toThrow( /--location flag requires a value/ ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js new file mode 100644 index 000000000..f37ef2465 --- /dev/null +++ b/__tests__/lib/edge-workers/project.js @@ -0,0 +1,262 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { readPrebuiltWorker, readWorkerSource } from '../../../src/lib/edge-workers'; +import { + CONVENTIONAL_PROJECT_DIR, + discoverWorkers, + findWorker, + readProjectDescriptor, + readWorkerManifest, + resolveProjectDir, + writeProjectDescriptor, + writeWorkerManifest, +} from '../../../src/lib/edge-workers/project'; + +function makeProject( root ) { + fs.mkdirSync( root, { recursive: true } ); + writeProjectDescriptor( root, { type: 'assemblyscript' } ); + return root; +} + +function makeWorker( root, name, manifest = {} ) { + const dir = path.join( root, 'workers', name ); + fs.mkdirSync( dir, { recursive: true } ); + writeWorkerManifest( dir, { name, entry: 'assembly/index.ts', ...manifest } ); + return dir; +} + +describe( 'edge-workers project', () => { + let tmp; + + beforeEach( () => { + tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-test-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmp, { recursive: true, force: true } ); + } ); + + describe( 'resolveProjectDir', () => { + it( 'resolves an explicit --path containing a descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( resolveProjectDir( { path: 'proj' }, tmp ) ).toBe( project ); + } ); + + it( 'throws when --path has no descriptor', () => { + fs.mkdirSync( path.join( tmp, 'empty' ) ); + expect( () => resolveProjectDir( { path: 'empty' }, tmp ) ).toThrow( + /No edge-workers project/ + ); + } ); + + it( 'walks up from the cwd to find the descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + const deep = path.join( project, 'workers', 'a', 'assembly' ); + fs.mkdirSync( deep, { recursive: true } ); + expect( resolveProjectDir( {}, deep ) ).toBe( project ); + } ); + + it( 'falls back to the conventional subfolder', () => { + const project = makeProject( path.join( tmp, CONVENTIONAL_PROJECT_DIR ) ); + expect( resolveProjectDir( {}, tmp ) ).toBe( project ); + } ); + + it( 'throws with guidance when nothing is found', () => { + expect( () => resolveProjectDir( {}, tmp ) ).toThrow( /vip edge-workers init/ ); + } ); + + it( 'rejects a --path flag passed without a value', () => { + // A value-less `--path` arrives as boolean true from the arg parser. + expect( () => resolveProjectDir( { path: true }, tmp ) ).toThrow( + /--path flag requires a path/ + ); + } ); + } ); + + describe( 'descriptor', () => { + it( 'round-trips the descriptor', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( readProjectDescriptor( project ) ).toEqual( { type: 'assemblyscript' } ); + } ); + + it( 'throws when the descriptor lacks a type', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), '{}' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /missing a "type"/ ); + } ); + + it( 'rejects an unsupported descriptor type', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), '{"type":"rust"}' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /invalid "type"/ ); + } ); + + it( 'rejects a null descriptor', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + fs.writeFileSync( path.join( project, 'edge-workers.json' ), 'null' ); + expect( () => readProjectDescriptor( project ) ).toThrow( /invalid "type"/ ); + } ); + + it( 'rejects a symlinked descriptor', () => { + const project = path.join( tmp, 'proj' ); + fs.mkdirSync( project, { recursive: true } ); + const outside = path.join( tmp, 'outside.json' ); + fs.writeFileSync( outside, '{"type":"assemblyscript"}' ); + fs.symlinkSync( outside, path.join( project, 'edge-workers.json' ), 'file' ); + expect( () => readProjectDescriptor( project ) ).toThrow( + /project descriptor at .* must not be a symbolic link/ + ); + } ); + } ); + + describe( 'worker manifests', () => { + it( 'rejects a null manifest', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + fs.writeFileSync( path.join( worker, 'worker.json' ), 'null' ); + expect( () => readWorkerManifest( worker ) ).toThrow( /must be an object/ ); + } ); + + it( 'rejects an entry outside the worker directory', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + fs.writeFileSync( + path.join( worker, 'worker.json' ), + '{"name":"demo","entry":"../outside.ts"}' + ); + expect( () => readWorkerManifest( worker ) ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects a symlinked manifest', () => { + const worker = path.join( tmp, 'worker' ); + fs.mkdirSync( worker, { recursive: true } ); + const outside = path.join( tmp, 'outside-manifest.json' ); + fs.writeFileSync( outside, '{"name":"demo","entry":"assembly/index.ts"}' ); + fs.symlinkSync( outside, path.join( worker, 'worker.json' ), 'file' ); + expect( () => readWorkerManifest( worker ) ).toThrow( + /worker manifest at .* must not be a symbolic link/ + ); + } ); + } ); + + describe( 'discoverWorkers / findWorker', () => { + it( 'discovers workers sorted by name and ignores dirs without a manifest', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'beta' ); + makeWorker( project, 'alpha' ); + fs.mkdirSync( path.join( project, 'workers', 'no-manifest' ), { recursive: true } ); + + const names = discoverWorkers( project ).map( worker => worker.manifest.name ); + expect( names ).toEqual( [ 'alpha', 'beta' ] ); + } ); + + it( 'returns an empty list when there is no workers dir', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + expect( discoverWorkers( project ) ).toEqual( [] ); + } ); + + it( 'rejects case-insensitive duplicate manifest names', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'first', { name: 'Headers' } ); + makeWorker( project, 'second', { name: 'headers' } ); + expect( () => discoverWorkers( project ) ).toThrow( /Duplicate worker name/ ); + } ); + + it( 'finds a worker by name', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'alpha' ); + expect( findWorker( project, 'alpha' ).manifest.name ).toBe( 'alpha' ); + } ); + + it( 'prefers an exact manifest name over a directory-name fallback', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'target', { name: 'directory-fallback' } ); + makeWorker( project, 'other-directory', { name: 'target' } ); + + expect( findWorker( project, 'target' ).manifest.name ).toBe( 'target' ); + } ); + + it( 'throws listing available workers when not found', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'alpha' ); + expect( () => findWorker( project, 'nope' ) ).toThrow( /Available workers: alpha/ ); + } ); + } ); + + describe( 'worker source and artifacts', () => { + it( 'throws when worker source cannot be read', () => { + const worker = { + dir: path.join( tmp, 'worker' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + expect( () => readWorkerSource( worker ) ).toThrow( /Could not read worker source/ ); + } ); + + it( 'rejects an entry symlink that escapes the worker directory', () => { + const workerDir = path.join( tmp, 'worker' ); + const entryDir = path.join( workerDir, 'assembly' ); + const outside = path.join( tmp, 'secret.ts' ); + fs.mkdirSync( entryDir, { recursive: true } ); + fs.writeFileSync( outside, 'TOP_SECRET_SOURCE' ); + fs.symlinkSync( outside, path.join( entryDir, 'index.ts' ), 'file' ); + + const worker = { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readWorkerSource( worker ) ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects traversal in prebuilt artifact names', () => { + const worker = { + dir: path.join( tmp, 'worker' ), + manifest: { name: '../outside', entry: 'assembly/index.ts' }, + }; + expect( () => readPrebuiltWorker( path.join( tmp, 'project' ), worker ) ).toThrow( + /Invalid worker name/ + ); + } ); + + it( 'rejects a prebuilt artifact symlink that escapes the project', () => { + const project = makeProject( path.join( tmp, 'project' ) ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'secret.wasm' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outside, 'TOP_SECRET_WASM' ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readPrebuiltWorker( project, worker ) ).toThrow( + /Worker build artifact must not be a symbolic link/ + ); + } ); + + it( 'rejects a prebuilt artifact symlink that escapes the build directory', () => { + const project = makeProject( path.join( tmp, 'project' ) ); + const buildDir = path.join( project, 'build' ); + const outsideBuild = path.join( project, 'project-secret.wasm' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outsideBuild, 'PROJECT_SECRET_WASM' ); + fs.symlinkSync( outsideBuild, path.join( buildDir, 'demo.wasm' ), 'file' ); + + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => readPrebuiltWorker( project, worker ) ).toThrow( + /Worker build artifact must not be a symbolic link/ + ); + } ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js new file mode 100644 index 000000000..5b65d5c1d --- /dev/null +++ b/__tests__/lib/edge-workers/toolchains.js @@ -0,0 +1,264 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { readProjectDescriptor, readWorkerManifest } from '../../../src/lib/edge-workers/project'; +import { getToolchain } from '../../../src/lib/edge-workers/toolchains'; + +jest.mock( 'node:child_process', () => ( { + spawnSync: jest.fn(), +} ) ); + +describe( 'edge-workers toolchains', () => { + let tmp; + + beforeEach( () => { + tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-tc-' ) ); + spawnSync.mockClear(); + spawnSync.mockImplementation( ( _command, args ) => { + const outFile = args[ args.indexOf( '--outFile' ) + 1 ]; + fs.writeFileSync( outFile, 'compiled wasm' ); + return { status: 0, stderr: '', stdout: '' }; + } ); + } ); + + afterEach( () => { + fs.rmSync( tmp, { recursive: true, force: true } ); + } ); + + it( 'throws for an unknown type', () => { + expect( () => getToolchain( 'rust' ) ).toThrow( /Unknown edge worker type/ ); + } ); + + describe( 'assemblyscript', () => { + const tc = getToolchain( 'assemblyscript' ); + + it( 'scaffolds a project with the expected layout', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + + expect( readProjectDescriptor( project ).type ).toBe( 'assemblyscript' ); + expect( fs.existsSync( path.join( project, 'package.json' ) ) ).toBe( true ); + expect( fs.existsSync( path.join( project, 'tsconfig.json' ) ) ).toBe( true ); + expect( fs.existsSync( path.join( project, 'workers' ) ) ).toBe( true ); + + const pkg = JSON.parse( fs.readFileSync( path.join( project, 'package.json' ), 'utf8' ) ); + expect( pkg.dependencies[ '@automattic/vip-edge-workers-sdk' ] ).toBe( '0.3.2' ); + expect( pkg.devDependencies.assemblyscript ).toBe( '0.27.0' ); + + const readme = fs.readFileSync( path.join( project, 'README.md' ), 'utf8' ); + expect( readme ).toContain( 'Commit the generated `package-lock.json`' ); + } ); + + it( 'scaffolds a project in an existing empty directory', () => { + const project = path.join( tmp, 'empty-proj' ); + fs.mkdirSync( project ); + + tc.scaffoldProject( project ); + + expect( fs.existsSync( path.join( project, 'edge-workers.json' ) ) ).toBe( true ); + } ); + + it( 'refuses a non-empty target before replacing files', () => { + const project = path.join( tmp, 'existing-app' ); + fs.mkdirSync( project ); + fs.writeFileSync( path.join( project, 'package.json' ), '{"name":"customer-app"}\n' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not empty/ ); + expect( fs.readFileSync( path.join( project, 'package.json' ), 'utf8' ) ).toBe( + '{"name":"customer-app"}\n' + ); + expect( fs.existsSync( path.join( project, 'edge-workers.json' ) ) ).toBe( false ); + } ); + + it( 'refuses a file target', () => { + const project = path.join( tmp, 'not-a-directory' ); + fs.writeFileSync( project, 'customer content\n' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not a directory/ ); + expect( fs.readFileSync( project, 'utf8' ) ).toBe( 'customer content\n' ); + } ); + + it( 'refuses a symlinked directory target', () => { + const realDir = path.join( tmp, 'real-dir' ); + fs.mkdirSync( realDir ); + const project = path.join( tmp, 'linked-app' ); + fs.symlinkSync( realDir, project, 'dir' ); + + expect( () => tc.scaffoldProject( project ) ).toThrow( /not a directory/ ); + expect( fs.existsSync( path.join( realDir, 'edge-workers.json' ) ) ).toBe( false ); + } ); + + it( 'scaffolds a worker with a manifest and entry file', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + tc.scaffoldWorker( project, 'my-worker' ); + + const workerDir = path.join( project, 'workers', 'my-worker' ); + expect( readWorkerManifest( workerDir ) ).toEqual( { + name: 'my-worker', + entry: 'assembly/index.ts', + } ); + expect( fs.existsSync( path.join( workerDir, 'assembly', 'index.ts' ) ) ).toBe( true ); + + const source = fs.readFileSync( path.join( workerDir, 'assembly', 'index.ts' ), 'utf8' ); + expect( source ).toContain( 'on_client_response' ); + expect( source ).not.toMatch( /^\s*on_client_request,?$/m ); + expect( source ).not.toMatch( /^\s*on_origin_request,?$/m ); + expect( source ).not.toMatch( /^\s*on_origin_response,?$/m ); + } ); + + it( 'refuses to scaffold a worker that already exists', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + tc.scaffoldWorker( project, 'dup' ); + expect( () => tc.scaffoldWorker( project, 'dup' ) ).toThrow( /already exists/ ); + } ); + + it( 'rejects a worker name that escapes the workers directory', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.scaffoldWorker( project, '../outside' ) ).toThrow( /Invalid worker name/ ); + expect( fs.existsSync( path.join( tmp, 'outside' ) ) ).toBe( false ); + } ); + + it( 'rejects an entry that escapes the worker directory before compiling', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const worker = { + dir: path.join( project, 'workers', 'demo' ), + manifest: { name: 'demo', entry: '../outside.ts' }, + }; + expect( () => tc.compile( project, worker ) ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects an entry symlink that escapes the worker directory before compiling', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entryDir = path.join( workerDir, 'assembly' ); + const outside = path.join( tmp, 'secret.ts' ); + fs.mkdirSync( entryDir, { recursive: true } ); + fs.writeFileSync( outside, 'TOP_SECRET_SOURCE' ); + fs.symlinkSync( outside, path.join( entryDir, 'index.ts' ), 'file' ); + const worker = { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + }; + + expect( () => tc.compile( project, worker ) ).toThrow( /Worker entry must stay within/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + } ); + + it( 'rejects a worker name that escapes the build directory', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + fs.mkdirSync( path.join( workerDir, 'assembly' ), { recursive: true } ); + fs.writeFileSync( path.join( workerDir, 'assembly', 'index.ts' ), 'export {};' ); + const worker = { + dir: workerDir, + manifest: { name: '../outside', entry: 'assembly/index.ts' }, + }; + expect( () => tc.compile( project, worker ) ).toThrow( /Invalid worker name/ ); + expect( fs.existsSync( path.join( tmp, 'outside.wasm' ) ) ).toBe( false ); + } ); + + it( 'rejects a symlinked build root before the compiler can write outside the project', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const outsideBuild = path.join( tmp, 'outside-build' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( outsideBuild ); + fs.symlinkSync( + outsideBuild, + path.join( project, 'build' ), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build directory must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.existsSync( path.join( outsideBuild, 'demo.wasm' ) ) ).toBe( false ); + } ); + + it( 'rejects a symlinked build output before the compiler can overwrite its target', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'outside.wasm' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( buildDir ); + fs.writeFileSync( outside, 'DO NOT OVERWRITE' ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build artifact must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.readFileSync( outside, 'utf8' ) ).toBe( 'DO NOT OVERWRITE' ); + } ); + + it( 'rejects a dangling build-output symlink before the compiler can create its target', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + const buildDir = path.join( project, 'build' ); + const outside = path.join( tmp, 'not-yet-created.wasm' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + fs.mkdirSync( buildDir ); + fs.symlinkSync( outside, path.join( buildDir, 'demo.wasm' ), 'file' ); + + expect( () => + tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ) + ).toThrow( /Worker build artifact must not be a symbolic link/ ); + expect( spawnSync ).not.toHaveBeenCalled(); + expect( fs.existsSync( outside ) ).toBe( false ); + } ); + + it( 'creates a real build directory inside the canonical project root', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + const workerDir = path.join( project, 'workers', 'demo' ); + const entry = path.join( workerDir, 'assembly', 'index.ts' ); + fs.mkdirSync( path.dirname( entry ), { recursive: true } ); + fs.writeFileSync( entry, 'export {};' ); + + const output = tc.compile( project, { + dir: workerDir, + manifest: { name: 'demo', entry: 'assembly/index.ts' }, + } ); + + expect( fs.lstatSync( path.join( project, 'build' ) ).isDirectory() ).toBe( true ); + expect( fs.lstatSync( path.join( project, 'build' ) ).isSymbolicLink() ).toBe( false ); + expect( fs.realpathSync.native( output ) ).toBe( + path.join( fs.realpathSync.native( project ), 'build', 'demo.wasm' ) + ); + } ); + + it( 'ensureAvailable throws when the compiler is missing', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.ensureAvailable( project ) ).toThrow( /npm install/ ); + } ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/validation.test.ts b/__tests__/lib/edge-workers/validation.test.ts new file mode 100644 index 000000000..6769a2858 --- /dev/null +++ b/__tests__/lib/edge-workers/validation.test.ts @@ -0,0 +1,186 @@ +import path from 'node:path'; + +import { + parseProjectDescriptor, + parseWorkerManifest, + resolvePathWithin, + validateWorkerName, +} from '../../../src/lib/edge-workers/validation'; + +describe( 'validateWorkerName', () => { + it.each( [ 'headers', 'security headers', 'worker.v2', 'A_worker-2' ] )( 'accepts %s', name => { + expect( validateWorkerName( name ) ).toBe( name ); + } ); + + it.each( [ + '', + '.', + '..', + '../outside', + 'folder/worker', + 'folder\\worker', + 'bad:name', + 'trailing.', + 'trailing ', + 'CON', + 'com1', + 'a'.repeat( 65 ), + ] )( 'rejects %s', name => { + expect( () => validateWorkerName( name ) ).toThrow( /Invalid worker name/ ); + } ); +} ); + +describe( 'resolvePathWithin', () => { + it( 'resolves below the root', () => { + expect( resolvePathWithin( '/project/workers/demo', 'assembly/index.ts', 'entry' ) ).toBe( + path.resolve( '/project/workers/demo/assembly/index.ts' ) + ); + } ); + + it( 'rejects traversal outside the root', () => { + expect( () => resolvePathWithin( '/project/workers/demo', '../secret.ts', 'entry' ) ).toThrow( + /must stay within/ + ); + } ); + + it( 'rejects absolute paths', () => { + expect( () => resolvePathWithin( '/project/workers/demo', '/tmp/secret.ts', 'entry' ) ).toThrow( + /relative path/ + ); + } ); +} ); + +describe( 'parseProjectDescriptor', () => { + it.each( [ null, [], 'assemblyscript' ] )( 'rejects non-object descriptors', value => { + expect( () => parseProjectDescriptor( value, '/project/edge-workers.json' ) ).toThrow( + /invalid "type" field/ + ); + } ); + + it( 'rejects unknown project types', () => { + expect( () => + parseProjectDescriptor( { type: 'rust' }, '/project/edge-workers.json' ) + ).toThrow( /invalid "type" field/ ); + } ); + + it( 'parses a supported descriptor', () => { + expect( + parseProjectDescriptor( + { type: 'assemblyscript', sdk: 'example@1.0.0' }, + '/project/edge-workers.json' + ) + ).toEqual( { type: 'assemblyscript', sdk: 'example@1.0.0' } ); + } ); +} ); + +describe( 'parseWorkerManifest', () => { + const file = '/project/workers/demo/worker.json'; + + it( 'rejects null manifests', () => { + expect( () => parseWorkerManifest( null, file ) ).toThrow( /must be an object/ ); + } ); + + it.each( [ { name: 'demo' }, { name: 'demo', entry: '' } ] )( + 'rejects manifests missing an entry', + value => { + expect( () => parseWorkerManifest( value, file ) ).toThrow( /missing an "entry" field/ ); + } + ); + + it( 'rejects entries escaping the worker directory', () => { + expect( () => + parseWorkerManifest( + { + name: 'demo', + entry: '../outside.ts', + }, + file + ) + ).toThrow( /Worker entry must stay within/ ); + } ); + + it( 'rejects invalid worker names', () => { + expect( () => + parseWorkerManifest( + { + name: 'folder/worker', + entry: 'assembly/index.ts', + }, + file + ) + ).toThrow( /Invalid worker name/ ); + } ); + + it.each( [ + { operator: 'matches', value: '/news' }, + { operator: 'contains', value: '' }, + ] )( 'rejects invalid locations', location => { + expect( () => + parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts', location }, file ) + ).toThrow( /invalid location/ ); + } ); + + it.each( [ '\u001b[31m/admin', '/safe\nforged output' ] )( + 'rejects control characters in location value %p', + value => { + expect( () => + parseWorkerManifest( + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value }, + }, + file + ) + ).toThrow( /invalid location value/ ); + } + ); + + it( 'rejects invalid failure behavior', () => { + expect( () => + parseWorkerManifest( + { name: 'demo', entry: 'assembly/index.ts', on_failure: 'ignore' }, + file + ) + ).toThrow( /invalid "on_failure" field/ ); + } ); + + it( 'parses an absent location', () => { + expect( parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts' }, file ) ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + } ); + } ); + + it( 'parses a null location', () => { + expect( + parseWorkerManifest( { name: 'demo', entry: 'assembly/index.ts', location: null }, file ) + ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + location: null, + } ); + } ); + + it.each( [ 'continue', 'error' ] )( + 'parses valid location objects and %s failure behavior', + onFailure => { + expect( + parseWorkerManifest( + { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/news' }, + on_failure: onFailure, + }, + file + ) + ).toEqual( { + name: 'demo', + entry: 'assembly/index.ts', + location: { operator: 'starts_with', value: '/news' }, + on_failure: onFailure, + } ); + } + ); +} ); diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md new file mode 100644 index 000000000..90d1c3d9e --- /dev/null +++ b/docs/EDGE-WORKERS.md @@ -0,0 +1,248 @@ +# Edge workers + +This guide is the operator contract for scaffolding, validating, deploying, and managing VIP +edge workers with VIP-CLI. + +## 1. Prerequisites and the inactive-create guarantee + +Use Node.js 22.19.0 or newer, npm 8 or newer, an authenticated VIP-CLI session, and access to the +target application and environment. Start in a non-production environment. + +The platform API creates every new worker with `active: false`; create does not accept an active +input. The API also applies a database default of inactive as defense in depth. VIP-CLI relies on +this enforced contract: deploy uploads a new worker first, confirms the returned inactive state, +and enables it only when the operator explicitly passes `--enable`. + +## 2. Project layout and exact dependency versions + +`vip edge-workers init` creates the project root; `new` and `build` extend it with the per-worker +and generated paths shown below: + +```text +edge-workers/ +├── edge-workers.json +├── package.json +├── tsconfig.json +├── lib/ # optional shared modules +├── build/ # created by build +└── workers/ + └── / + ├── worker.json + └── assembly/index.ts +``` + +`build/` is created when a worker is compiled and is ignored by Git. The generated direct +dependencies are exact: `@automattic/vip-edge-workers-sdk` is `0.3.2` and `assemblyscript` is +`0.27.0`. The starter exports only `alloc` and `on_client_response`; the other request phases are +commented examples and are not active WASM exports. + +## 3. `edge-workers.json` schema + +The project descriptor selects the toolchain for every worker in the project: + +```json +{ + "type": "assemblyscript", + "sdk": "@automattic/vip-edge-workers-sdk@0.3.2" +} +``` + +`type` is required and currently accepts only `assemblyscript`. `sdk` is optional metadata that +records the generated SDK dependency. Workers are discovered from `workers/*/worker.json`; the +project descriptor is not a worker registry. + +## 4. `worker.json` schema and location tri-state + +Each worker has its own manifest: + +```json +{ + "name": "security-headers", + "entry": "assembly/index.ts", + "location": { + "operator": "starts_with", + "value": "/api/" + }, + "on_failure": "continue" +} +``` + +- `name` is required, is unique within an environment, and must be a portable file name of at most + 64 characters. Path separators, control characters, Windows-reserved names, `.` and `..`, and + trailing dots or spaces are rejected. +- `entry` is required, must be relative, and must stay inside the worker directory. +- `location` is optional. Its `operator` is `contains`, `equals`, `starts_with`, or `ends_with`, and + `value` must be a non-empty string. +- `on_failure` is optional and is either `continue` or `error`. + +`location` has three distinct update states: + +| Manifest state | Create | Update | +| --------------- | --------------------- | --------------------------------------------------- | +| Omitted | Apply to all requests | Preserve the stored location | +| `null` | Apply to all requests | Clear the stored location and apply to all requests | +| Location object | Store that location | Replace the stored location | + +## 5. Safe scaffold workflow and the lockfile + +Initialize only an absent or empty target directory, install the pinned direct dependencies, and +commit the npm lockfile: + +```sh +vip edge-workers init +cd edge-workers +npm install +git add package-lock.json +git commit -m "build: lock edge-worker dependencies" +vip edge-workers new security-headers --location starts_with:/api/ +``` + +`new` validates the worker name and location before writing. Without `--location`, it reports that +the worker applies to all requests and directs you to edit `worker.json` before deployment if that +scope is too broad. A generated worker activates only the `client_response` phase. Implement and +review that handler before activating any commented phase example. + +Use `--path ` with `new`, `build`, `validate`, or `deploy` when auto-discovery should not be +used. + +## 6. Build and validation limits + +Build one worker with `vip edge-workers build `. Both `vip edge-workers build` and +`vip edge-workers build --all` build every discovered worker. Each successful line reports the +relative `build/.wasm` path and exact byte size. The build stops on the first compiler error; +an empty project is an error. + +Validate against a non-production environment before deploying: + +```sh +vip @example-app.develop edge-workers validate security-headers +vip @example-app.develop edge-workers validate --all +``` + +Validation parses each selected worker manifest. A normal build also reads the project descriptor; +then validation compiles the worker and sends the compiled WASM to the environment's server-side +dry-run validator. It reports validity and detected phases. Validation does not execute requests +and does not prove runtime behavior, performance, routing correctness, or application +compatibility. `--skip-build` reads the existing `build/.wasm`; it does not verify that the +artifact matches the current source. + +## 7. Deployment plan fields and production confirmation + +Deploy prepares every selected worker before applying any remote mutation. Preparation reconciles +the name as a create or update, builds or reads the artifact, validates it unless +`--skip-validate` is passed, determines location and source behavior, and then prints a plan with: + +- `worker`, `action`, `current_active`, and `final_active`; +- `current_scope` and `proposed_scope`; +- `validation` and detected `phases`; +- compiled `bytes`; and +- `source` mode (`store`, `omit`, or `preserve`). + +Review the entire plan. A production deploy requires an interactive confirmation naming every +worker, unless the operator deliberately passes `--skip-confirmation`. A worker name and `--all` +cannot be combined. `--skip-build`, `--skip-validate`, and `--skip-confirmation` remove safety +checks and should be used only when the omitted step has separate, current evidence. + +Deploy is upload-only by default. A newly created worker remains inactive, and an update to an +inactive worker remains inactive. Pass `--enable` to enable a newly created or currently inactive +worker after its upload succeeds. An update to an already-active worker stays active and skips the +redundant enable request, even when `--enable` is present; the uploaded code and configuration +therefore become live immediately. Disable an active worker first when the update must not become +live on deployment. + +The plan and the single deployment confirmation cover both the upload and requested enable phase. +For `--all`, every worker's planned final state is visible before any remote mutation begins. + +## 8. Source storage and `--skip-source` + +By default, deploy stores the worker's UTF-8 entry file alongside the WASM binary. It does not +archive the full project or shared modules. `get` omits source by default; pass `--source` to make +the additional on-demand source query and print the stored value. + +`--skip-source` means: do not store source on create; preserve stored source on update. Without +the flag, an update replaces the stored source with the current entry file, including an empty +file. The plan's `source` column shows the selected behavior before mutation. + +## 9. Enable, disable, delete, and rollback + +- `enable ` makes a deployed worker active. Production requires confirmation or the explicit + `--skip-confirmation` bypass. +- `disable ` makes a deployed worker inactive. +- `delete ` permanently removes a deployed worker. It prompts in every environment unless + `--force` is passed. + +There is no automatic rollback command and an `--all` failure is not rolled back. To recover, +disable the affected worker first, inspect its current state, then deploy a reviewed known-good +source/artifact or delete the worker if permanent removal is intended. Do not describe a redeploy +as a rollback unless the exact prior source, manifest, dependencies, and compiled artifact are +available and verified. + +If the enable phase of `deploy --enable` fails or times out, the worker's final active state is +unknown. The command reports the last confirmed upload result and does not retry, roll back, +disable, or delete the worker automatically. Use `edge-workers list` and `edge-workers get ` +to verify the remote state before taking another action. Do not assume the worker remained +inactive. + +## 10. `--all` and partial failures + +`build` with no name, `build --all`, `validate --all`, and `deploy --all` operate on workers in +stable name order. Deploy preparation completes for all selected workers before remote writes +begin, so a preparation or validation failure applies none of them. + +Application is sequential. Each worker's upload completes before its enable phase can begin. If a +create or update fails, deployment stops immediately and reports the workers already applied, the +failed worker, the workers not applied, and the original cause. If an enable fails, deployment +reports that worker's confirmed upload state, treats its final active state as unknown, and stops +before later workers. It does not retry, roll back, disable, or delete already-applied workers. +Reconcile the reported names with `list` and `get` before retrying. + +## 11. Automation flags and `VIP_NON_INTERACTIVE=1` + +Automation should provide an explicit `@app.environment` alias (or equivalent app/environment +options), an explicit worker name or `--all`, and `--path` when the working directory is not inside +the project. Relevant bypasses are `--skip-build`, `--skip-validate`, `--skip-source`, and +`--skip-confirmation` for deploy. Deploy also accepts `--enable`, which is an action request rather +than a safety bypass and defaults to false. Validate accepts `--skip-build`; enable accepts +`--skip-confirmation`; delete accepts `--force`. `list` supports the global `--format` output +option. There is no edge-workers `--non-interactive` flag or other confirmation bypass. + +Set `VIP_NON_INTERACTIVE=1` to prevent interactive edge-worker production confirmation. In that +mode, production `deploy` and `enable` fail closed unless `--skip-confirmation` is also supplied. +The environment variable is not approval: the bypass flag must represent an explicit operator or +pipeline authorization. Delete confirmation is independent; use `--force` only with equivalent +authorization. + +## 12. Non-production manual lifecycle + +Exercise the complete lifecycle on a non-production environment first: + +```sh +vip edge-workers init +cd edge-workers +npm install +# Review and commit package-lock.json. +vip edge-workers new security-headers --location starts_with:/api/ +# Implement and review workers/security-headers/assembly/index.ts. +vip edge-workers build security-headers +vip @example-app.develop edge-workers validate security-headers +vip @example-app.develop edge-workers deploy security-headers +vip @example-app.develop edge-workers list +vip @example-app.develop edge-workers get security-headers --source +vip @example-app.develop edge-workers enable security-headers +# Send controlled requests and observe application behavior. +vip @example-app.develop edge-workers disable security-headers +# Update the source while inactive, then upload and explicitly enable after the upload. +vip @example-app.develop edge-workers deploy security-headers --enable +# An update while already active becomes live on upload and skips a redundant enable request. +vip @example-app.develop edge-workers deploy security-headers --enable +vip @example-app.develop edge-workers disable security-headers +# Set location to null in worker.json and deploy to clear the stored location. +# Omit location on a later update to preserve the stored location. +# Confirm permanent deletion when prompted. +vip @example-app.develop edge-workers delete security-headers +``` + +Before each enable, verify that the printed deployment plan matches the reviewed artifact, phases, +scope, source mode, byte size, and final active state. If enable does not return a confirmed +result, stop and verify with `list` and `get`; do not infer the final state. Promote to production +only after the non-production lifecycle and a separate production change review succeed. diff --git a/package-lock.json b/package-lock.json index 3e8ac5584..0332fd8f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,6 +134,17 @@ "vip-dev-env-sync": "dist/bin/vip-dev-env-sync.js", "vip-dev-env-sync-sql": "dist/bin/vip-dev-env-sync-sql.js", "vip-dev-env-update": "dist/bin/vip-dev-env-update.js", + "vip-edge-workers": "dist/bin/vip-edge-workers.js", + "vip-edge-workers-build": "dist/bin/vip-edge-workers-build.js", + "vip-edge-workers-delete": "dist/bin/vip-edge-workers-delete.js", + "vip-edge-workers-deploy": "dist/bin/vip-edge-workers-deploy.js", + "vip-edge-workers-disable": "dist/bin/vip-edge-workers-disable.js", + "vip-edge-workers-enable": "dist/bin/vip-edge-workers-enable.js", + "vip-edge-workers-get": "dist/bin/vip-edge-workers-get.js", + "vip-edge-workers-init": "dist/bin/vip-edge-workers-init.js", + "vip-edge-workers-list": "dist/bin/vip-edge-workers-list.js", + "vip-edge-workers-new": "dist/bin/vip-edge-workers-new.js", + "vip-edge-workers-validate": "dist/bin/vip-edge-workers-validate.js", "vip-export": "dist/bin/vip-export.js", "vip-export-sql": "dist/bin/vip-export-sql.js", "vip-import": "dist/bin/vip-import.js", diff --git a/package.json b/package.json index 7906de8d0..21c508ea0 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,17 @@ "vip-dev-env-stop": "dist/bin/vip-dev-env-stop.js", "vip-dev-env-logs": "dist/bin/vip-dev-env-logs.js", "vip-dev-env-purge": "dist/bin/vip-dev-env-purge.js", + "vip-edge-workers": "dist/bin/vip-edge-workers.js", + "vip-edge-workers-init": "dist/bin/vip-edge-workers-init.js", + "vip-edge-workers-new": "dist/bin/vip-edge-workers-new.js", + "vip-edge-workers-build": "dist/bin/vip-edge-workers-build.js", + "vip-edge-workers-validate": "dist/bin/vip-edge-workers-validate.js", + "vip-edge-workers-list": "dist/bin/vip-edge-workers-list.js", + "vip-edge-workers-get": "dist/bin/vip-edge-workers-get.js", + "vip-edge-workers-deploy": "dist/bin/vip-edge-workers-deploy.js", + "vip-edge-workers-enable": "dist/bin/vip-edge-workers-enable.js", + "vip-edge-workers-disable": "dist/bin/vip-edge-workers-disable.js", + "vip-edge-workers-delete": "dist/bin/vip-edge-workers-delete.js", "vip-export": "dist/bin/vip-export.js", "vip-export-sql": "dist/bin/vip-export-sql.js", "vip-dev-env-sync": "dist/bin/vip-dev-env-sync.js", diff --git a/src/bin/vip-edge-workers-build.js b/src/bin/vip-edge-workers-build.js new file mode 100644 index 000000000..1b80e07a9 --- /dev/null +++ b/src/bin/vip-edge-workers-build.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker } from '../lib/edge-workers'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEvent } from '../lib/tracker'; +import UserError from '../lib/user-error'; + +const usage = 'vip edge-workers build'; + +const examples = [ + { + usage: 'vip edge-workers build', + description: 'Compile every worker in the project to WebAssembly.', + }, + { + usage: 'vip edge-workers build my-worker', + description: 'Compile a single worker.', + }, +]; + +export async function edgeWorkersBuildCommand( args = [], opt = {} ) { + const name = args[ 0 ]; + + await trackEvent( 'edge_workers_build_command_execute', { name, all: Boolean( opt.all ) } ); + + try { + if ( name && opt.all ) { + throw new UserError( 'Supply either a worker name or --all, not both.' ); + } + + const projectDir = resolveProjectDir( { path: opt.path } ); + + const workers = name ? [ findWorker( projectDir, name ) ] : discoverWorkers( projectDir ); + + if ( ! workers.length ) { + throw new UserError( + 'No workers found in this project. Create one with `vip edge-workers new`.' + ); + } + + for ( const worker of workers ) { + const { wasmPath, sizeBytes } = buildWorker( projectDir, worker ); + console.log( + `✓ Built "${ escapeTerminalText( worker.manifest.name ) }" → ${ escapeTerminalText( + path.relative( projectDir, wasmPath ) + ) } (${ sizeBytes } bytes)` + ); + } + + await trackEvent( 'edge_workers_build_command_success', { count: workers.length } ); + } catch ( err ) { + await trackEvent( 'edge_workers_build_command_error', { name, error: 'build_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); + } +} + +command( { + requiredArgs: 0, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Compile every worker in the project.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersBuildCommand ); diff --git a/src/bin/vip-edge-workers-delete.js b/src/bin/vip-edge-workers-delete.js new file mode 100644 index 000000000..277728809 --- /dev/null +++ b/src/bin/vip-edge-workers-delete.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { appQuery, deleteEdgeWorker, findEdgeWorkerByName } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { confirmEdgeWorkerDeletion } from '../lib/edge-workers/confirmation'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { confirm } from '../lib/envvar/input'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers delete'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers delete my-worker', + description: 'Permanently delete the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersDeleteCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await confirmEdgeWorkerDeletion( + { + appName: app.name, + envType: env.type, + workerName: worker.name, + force: Boolean( opt.force ), + }, + confirm + ); + + await deleteEdgeWorker( env.id, worker.id ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_success', { name } ); + console.log( `✓ Deleted edge worker "${ escapeTerminalText( name ) }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_error', { + name, + error: 'delete_failed', + } ); + exit.withError( `Failed to delete edge worker: ${ escapeTerminalText( err.message ) }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .option( 'force', 'Skip confirmation.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersDeleteCommand ); diff --git a/src/bin/vip-edge-workers-deploy.js b/src/bin/vip-edge-workers-deploy.js new file mode 100644 index 000000000..f92a64ea2 --- /dev/null +++ b/src/bin/vip-edge-workers-deploy.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { appQuery } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { formatData } from '../lib/cli/format'; +import { + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../lib/edge-workers/confirmation'; +import { + applyEdgeWorkerDeploymentPlan, + DeploymentApplyError, + deploymentPlanRows, + prepareEdgeWorkerDeploymentPlan, +} from '../lib/edge-workers/deployment'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { confirm } from '../lib/envvar/input'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers deploy'; + +const examples = [ + { + usage: 'vip @example-app.develop edge-workers deploy my-worker', + description: 'Compile and deploy a single worker to the develop environment.', + }, + { + usage: 'vip @example-app.develop edge-workers deploy --all', + description: 'Compile and deploy every worker in the project.', + }, + { + usage: 'vip @example-app.develop edge-workers deploy my-worker --skip-build', + description: 'Deploy a previously compiled artifact without recompiling.', + }, +]; + +function errorMessage( error ) { + return escapeTerminalText( error instanceof Error ? error.message : String( error ) ); +} + +function partialFailureMessage( error ) { + if ( error.stage === 'enable' ) { + const failedName = escapeTerminalText( error.failedName ); + let activeAfterUpload = 'unknown'; + if ( error.activeAfterUpload === true ) { + activeAfterUpload = 'active'; + } else if ( error.activeAfterUpload === false ) { + activeAfterUpload = 'inactive'; + } + return ( + `Deployment uploaded "${ failedName }" and its last confirmed state was ${ activeAfterUpload }, ` + + 'but the enable request failed. Final active state is unknown; verify with ' + + `\`vip edge-workers get ${ failedName }\` or \`vip edge-workers list\`. ` + + `Completed: ${ error.appliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + + `Not attempted: ${ + error.unappliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' + }. Cause: ${ errorMessage( error.cause ) }` + ); + } + return ( + `Deployment stopped at "${ escapeTerminalText( error.failedName ) }". ` + + `Applied: ${ error.appliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + + `Not applied: ${ error.unappliedNames.map( escapeTerminalText ).join( ', ' ) || 'none' }. ` + + `Cause: ${ errorMessage( error.cause ) }` + ); +} + +function appliedResultMessage( item, deployed ) { + const name = escapeTerminalText( item.worker.manifest.name ); + let result; + if ( item.action === 'create' ) { + result = deployed.active + ? `created "${ name }" and enabled it` + : `created "${ name }"; inactive`; + } else if ( deployed.active ) { + result = item.existing?.active + ? `updated "${ name }"; remains active` + : `updated "${ name }" and enabled it`; + } else { + result = `updated "${ name }"; remains inactive`; + } + + const phasesNote = `, phases: ${ + deployed.phases.map( escapeTerminalText ).join( ', ' ) || 'none' + }`; + return `✓ ${ result } (${ item.artifact.sizeBytes } bytes${ phasesNote })`; +} + +function inactiveCreateGuidance( workerNames ) { + const names = workerNames.map( name => `"${ escapeTerminalText( name ) }"` ).join( ', ' ); + if ( workerNames.length === 1 ) { + return `Review created inactive edge worker ${ names }, then run \`vip edge-workers enable \` when ready.`; + } + return `Review created inactive edge workers ${ names }, then run \`vip edge-workers enable \` for each one when ready.`; +} + +export async function edgeWorkersDeployCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + const enableAfterDeploy = Boolean( opt.enable ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_execute', { + name, + all: Boolean( opt.all ), + } ); + + try { + if ( name && opt.all ) { + throw new Error( 'Supply either a worker name or --all, not both.' ); + } + + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + throw new Error( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + throw new Error( 'Please supply a worker name to deploy, or pass `--all`.' ); + } + + const plan = await prepareEdgeWorkerDeploymentPlan( { + appId: app.id, + envId: env.id, + projectDir, + workers, + skipBuild: Boolean( opt.skipBuild ), + skipValidate: Boolean( opt.skipValidate ), + skipSource: Boolean( opt.skipSource ), + enableAfterDeploy, + } ); + + console.log( formatData( deploymentPlanRows( plan ), 'table' ) ); + + await confirmProductionEdgeWorkerMutation( + { + action: 'deploy', + appName: app.name, + envType: env.type, + workerNames: plan.map( item => item.worker.manifest.name ), + enableAfterDeploy, + skipConfirmation: Boolean( opt.skipConfirmation ), + nonInteractive: ! isInteractiveEdgeWorkers( opt ), + }, + confirm + ); + + const finalResults = []; + const createdInactiveNames = []; + await applyEdgeWorkerDeploymentPlan( env.id, plan, ( item, deployed ) => { + finalResults.push( deployed ); + if ( ! enableAfterDeploy && item.action === 'create' && ! deployed.active ) { + createdInactiveNames.push( item.worker.manifest.name ); + } + console.log( appliedResultMessage( item, deployed ) ); + } ); + if ( createdInactiveNames.length ) { + console.log( inactiveCreateGuidance( createdInactiveNames ) ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_success', { + count: plan.length, + enable: enableAfterDeploy, + activeCount: finalResults.filter( worker => worker.active ).length, + } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { + name, + error: 'deploy_failed', + } ); + exit.withError( + err instanceof DeploymentApplyError + ? partialFailureMessage( err ) + : `Failed to deploy edge worker: ${ errorMessage( err ) }` + ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Deploy every worker in the project.', false ) + .option( 'skip-build', 'Deploy a previously compiled artifact without recompiling.', false ) + .option( 'skip-validate', 'Skip server-side dry-run validation before uploading.', false ) + .option( + 'skip-source', + 'Do not store source on create; preserve stored source on update.', + false + ) + .option( 'enable', 'Enable each deployed worker after a successful upload.', false ) + .option( 'skip-confirmation', 'Skip the production deployment confirmation.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersDeployCommand ); diff --git a/src/bin/vip-edge-workers-disable.js b/src/bin/vip-edge-workers-disable.js new file mode 100644 index 000000000..b7e29101c --- /dev/null +++ b/src/bin/vip-edge-workers-disable.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers disable'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers disable my-worker', + description: 'Disable the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersDisableCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await setEdgeWorkerActive( env.id, worker.id, false ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_success', { name } ); + console.log( `✓ Disabled edge worker "${ escapeTerminalText( name ) }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_error', { + name, + error: 'disable_failed', + } ); + exit.withError( `Failed to disable edge worker: ${ escapeTerminalText( err.message ) }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersDisableCommand ); diff --git a/src/bin/vip-edge-workers-enable.js b/src/bin/vip-edge-workers-enable.js new file mode 100644 index 000000000..59087e0e0 --- /dev/null +++ b/src/bin/vip-edge-workers-enable.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { appQuery, findEdgeWorkerByName, setEdgeWorkerActive } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { + confirmProductionEdgeWorkerMutation, + isInteractiveEdgeWorkers, +} from '../lib/edge-workers/confirmation'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { confirm } from '../lib/envvar/input'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers enable'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers enable my-worker', + description: 'Enable the deployed worker named "my-worker".', + }, +]; + +export async function edgeWorkersEnableCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_execute', { name } ); + + try { + const worker = await findEdgeWorkerByName( app.id, env.id, name ); + if ( ! worker ) { + throw new Error( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await confirmProductionEdgeWorkerMutation( + { + action: 'enable', + appName: app.name, + envType: env.type, + workerNames: [ worker.name ], + skipConfirmation: Boolean( opt.skipConfirmation ), + nonInteractive: ! isInteractiveEdgeWorkers( opt ), + }, + confirm + ); + + await setEdgeWorkerActive( env.id, worker.id, true ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_success', { name } ); + console.log( `✓ Enabled edge worker "${ escapeTerminalText( name ) }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_error', { + name, + error: 'enable_failed', + } ); + exit.withError( `Failed to enable edge worker: ${ escapeTerminalText( err.message ) }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .option( 'skip-confirmation', 'Skip the production enable confirmation.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersEnableCommand ); diff --git a/src/bin/vip-edge-workers-get.js b/src/bin/vip-edge-workers-get.js new file mode 100644 index 000000000..bab27b15f --- /dev/null +++ b/src/bin/vip-edge-workers-get.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import { appQuery, getEdgeWorker } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { keyValue } from '../lib/cli/format'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers get'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers get my-worker', + description: 'Show details for the deployed worker named "my-worker".', + }, + { + usage: 'vip @example-app.production edge-workers get my-worker --source', + description: 'Also print the stored source code for the worker.', + }, +]; + +export async function edgeWorkersGetCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + const includeSource = opt.source === true; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_execute', { name } ); + + if ( ! name ) { + exit.withError( 'Please supply the name of an edge worker.' ); + } + + let worker; + try { + worker = await getEdgeWorker( app.id, env.id, name, { includeSource } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { + name, + error: 'get_failed', + } ); + exit.withError( `Failed to get edge worker: ${ escapeTerminalText( err.message ) }` ); + } + + if ( ! worker ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { + name, + error: 'Not found', + } ); + exit.withError( + `No edge worker named "${ escapeTerminalText( name ) }" is deployed to this environment.` + ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_success', { name } ); + + const location = worker.location + ? `${ escapeTerminalText( worker.location.operator ) } "${ escapeTerminalText( + worker.location.value + ) }"` + : 'all requests'; + + console.log( + keyValue( [ + { key: 'ID', value: escapeTerminalText( worker.id ) }, + { key: 'Name', value: escapeTerminalText( worker.name ) }, + { key: 'Active', value: worker.active ? 'yes' : 'no' }, + { key: 'Phases', value: ( worker.phases || [] ).map( escapeTerminalText ).join( ', ' ) }, + { key: 'Location', value: location }, + { key: 'On failure', value: escapeTerminalText( worker.onFailure ) }, + { key: 'Created', value: escapeTerminalText( worker.createdAt ) }, + { key: 'Modified', value: escapeTerminalText( worker.updatedAt ) }, + ] ) + ); + + if ( includeSource ) { + console.log( '\nSource:' ); + console.log( + worker.source === null || worker.source === undefined + ? '(no source stored)' + : escapeTerminalText( worker.source ) + ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .option( 'source', 'Print the stored source code for the worker.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersGetCommand ); diff --git a/src/bin/vip-edge-workers-init.js b/src/bin/vip-edge-workers-init.js new file mode 100644 index 000000000..17845b0df --- /dev/null +++ b/src/bin/vip-edge-workers-init.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { CONVENTIONAL_PROJECT_DIR } from '../lib/edge-workers/project'; +import { getToolchain } from '../lib/edge-workers/toolchains'; +import { DEFAULT_EDGE_WORKER_TYPE, SUPPORTED_EDGE_WORKER_TYPES } from '../lib/edge-workers/types'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip edge-workers init'; + +const examples = [ + { + usage: 'vip edge-workers init', + description: `Scaffold a new edge-workers project in ./${ CONVENTIONAL_PROJECT_DIR }.`, + }, + { + usage: 'vip edge-workers init ./infra/edge --type=assemblyscript', + description: 'Scaffold a project at a custom path with an explicit toolchain.', + }, +]; + +export async function edgeWorkersInitCommand( args = [], opt = {} ) { + const type = opt.type || DEFAULT_EDGE_WORKER_TYPE; + const targetArg = args[ 0 ] || CONVENTIONAL_PROJECT_DIR; + const projectDir = path.resolve( process.cwd(), targetArg ); + + await trackEvent( 'edge_workers_init_command_execute', { type } ); + + if ( ! SUPPORTED_EDGE_WORKER_TYPES.includes( type ) ) { + await trackEvent( 'edge_workers_init_command_error', { type, error: 'Unsupported type' } ); + exit.withError( + `Unsupported type "${ escapeTerminalText( + type + ) }". Supported types: ${ SUPPORTED_EDGE_WORKER_TYPES.join( ', ' ) }.` + ); + } + + try { + getToolchain( type ).scaffoldProject( projectDir ); + } catch ( err ) { + await trackEvent( 'edge_workers_init_command_error', { type, error: 'init_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); + } + + await trackEvent( 'edge_workers_init_command_success', { type } ); + + console.log( + `✓ Created a new ${ escapeTerminalText( type ) } edge-workers project in ${ escapeTerminalText( + projectDir + ) }` + ); + console.log( '\nNext steps:' ); + console.log( ` cd ${ escapeTerminalText( targetArg ) }` ); + console.log( ' npm install' ); + console.log( ' vip edge-workers new my-worker' ); +} + +command( { + requiredArgs: 0, + usage, +} ) + .option( + 'type', + `The worker toolchain to scaffold. Accepts ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }. Default is "${ DEFAULT_EDGE_WORKER_TYPE }".`, + DEFAULT_EDGE_WORKER_TYPE + ) + .examples( examples ) + .argv( process.argv, edgeWorkersInitCommand ); diff --git a/src/bin/vip-edge-workers-list.js b/src/bin/vip-edge-workers-list.js new file mode 100644 index 000000000..d0227fc26 --- /dev/null +++ b/src/bin/vip-edge-workers-list.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { appQuery, listEdgeWorkers } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers list'; + +const examples = [ + { + usage: 'vip @example-app.production edge-workers list', + description: 'List all edge workers deployed to the production environment.', + }, +]; + +function formatLocation( location, escape ) { + if ( ! location ) { + return 'all requests'; + } + + return `${ escape( location.operator ) } "${ escape( location.value ) }"`; +} + +export async function edgeWorkersListCommand( _args = [], opt = {} ) { + const { app, env } = opt; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_execute' ); + + let workers; + try { + workers = await listEdgeWorkers( app.id, env.id ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_error', { + error: 'list_failed', + } ); + exit.withError( `Failed to list edge workers: ${ escapeTerminalText( err.message ) }` ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_list_command_success', { + count: workers.length, + } ); + + if ( ! workers.length && opt.format !== 'json' ) { + console.log( 'No edge workers are deployed to this environment.' ); + return []; + } + + const escape = opt.format === 'json' ? value => value : escapeTerminalText; + + return workers.map( worker => ( { + id: worker.id, + name: escape( worker.name ), + active: worker.active ? 'yes' : 'no', + phases: ( worker.phases || [] ).map( escape ).join( ', ' ), + location: formatLocation( worker.location, escape ), + on_failure: escape( worker.onFailure ), + modified: escape( worker.updatedAt ), + } ) ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + format: true, + usage, +} ) + .examples( examples ) + .argv( process.argv, edgeWorkersListCommand ); diff --git a/src/bin/vip-edge-workers-new.js b/src/bin/vip-edge-workers-new.js new file mode 100644 index 000000000..9bfdf2c13 --- /dev/null +++ b/src/bin/vip-edge-workers-new.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { parseLocationOption } from '../lib/edge-workers/location'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { + readProjectDescriptor, + readWorkerManifest, + resolveProjectDir, + WORKERS_DIR, + writeWorkerManifest, +} from '../lib/edge-workers/project'; +import { getToolchain } from '../lib/edge-workers/toolchains'; +import { EDGE_WORKER_LOCATION_OPERATORS } from '../lib/edge-workers/types'; +import { validateWorkerName } from '../lib/edge-workers/validation'; +import { trackEvent } from '../lib/tracker'; + +const usage = 'vip edge-workers new'; + +const examples = [ + { + usage: 'vip edge-workers new add-security-headers', + description: 'Add a new worker named "add-security-headers" to the current project.', + }, + { + usage: 'vip edge-workers new my-worker --path ./infra/edge', + description: 'Add a worker to a project at a specific path.', + }, + { + usage: 'vip edge-workers new api-auth --location starts_with:/api/', + description: 'Add a worker that only runs on request paths under /api/.', + }, +]; + +export async function edgeWorkersNewCommand( args = [], opt = {} ) { + const name = args[ 0 ]; + + await trackEvent( 'edge_workers_new_command_execute', { name } ); + + if ( ! name ) { + await trackEvent( 'edge_workers_new_command_error', { error: 'Missing name' } ); + exit.withError( 'Please supply a name for the new worker.' ); + } + + try { + validateWorkerName( name ); + // Parse up front so a bad --location doesn't leave a half-created worker behind. + const location = opt.location !== undefined ? parseLocationOption( opt.location ) : undefined; + const projectDir = resolveProjectDir( { path: opt.path } ); + const descriptor = readProjectDescriptor( projectDir ); + getToolchain( descriptor.type ).scaffoldWorker( projectDir, name ); + + if ( location ) { + const workerDir = path.join( projectDir, WORKERS_DIR, name ); + writeWorkerManifest( workerDir, { ...readWorkerManifest( workerDir ), location } ); + } + + await trackEvent( 'edge_workers_new_command_success', { name, type: descriptor.type } ); + + const entryDir = path.join( WORKERS_DIR, name ); + console.log( + `✓ Created worker "${ escapeTerminalText( name ) }" in ${ escapeTerminalText( + path.join( projectDir, entryDir ) + ) }` + ); + console.log( + location + ? `Scope: ${ escapeTerminalText( location.operator ) } "${ escapeTerminalText( + location.value + ) }".` + : 'Scope: all requests. Set location in worker.json before deployment to narrow it.' + ); + console.log( '\nEdit the worker, then deploy it with:' ); + console.log( ` vip @my-site.develop edge-workers deploy ${ escapeTerminalText( name ) }` ); + } catch ( err ) { + await trackEvent( 'edge_workers_new_command_error', { name, error: 'new_failed' } ); + exit.withError( escapeTerminalText( err.message ) ); + } +} + +command( { + requiredArgs: 1, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( + 'location', + `Only run the worker on matching request paths, as ":". Operators: ${ EDGE_WORKER_LOCATION_OPERATORS.join( + ', ' + ) }.` + ) + .examples( examples ) + .argv( process.argv, edgeWorkersNewCommand ); diff --git a/src/bin/vip-edge-workers-validate.js b/src/bin/vip-edge-workers-validate.js new file mode 100644 index 000000000..4b3a71cfb --- /dev/null +++ b/src/bin/vip-edge-workers-validate.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { appQuery, validateEdgeWorker } from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker, readPrebuiltWorker } from '../lib/edge-workers'; +import { escapeTerminalText } from '../lib/edge-workers/output'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEventWithEnv } from '../lib/tracker'; + +const usage = 'vip edge-workers validate'; + +const examples = [ + { + usage: 'vip @example-app.develop edge-workers validate my-worker', + description: + 'Validate the local manifest and compiled WASM without deploying or executing requests.', + }, + { + usage: 'vip @example-app.develop edge-workers validate --all', + description: 'Validate every worker in the project.', + }, + { + usage: 'vip @example-app.develop edge-workers validate my-worker --skip-build', + description: 'Validate a previously compiled artifact without recompiling.', + }, +]; + +export async function edgeWorkersValidateCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_execute', { + name, + all: Boolean( opt.all ), + } ); + + let invalidCount = 0; + try { + if ( name && opt.all ) { + throw new Error( 'Supply either a worker name or --all, not both.' ); + } + + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + throw new Error( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + throw new Error( 'Please supply a worker name to validate, or pass `--all`.' ); + } + + // Validate sequentially for clear, ordered output. + for ( const worker of workers ) { + const artifact = opt.skipBuild + ? readPrebuiltWorker( projectDir, worker ) + : buildWorker( projectDir, worker ); + + // eslint-disable-next-line no-await-in-loop + const result = await validateEdgeWorker( env.id, artifact.base64 ); + + if ( result && ! result.valid ) { + invalidCount++; + const errors = + ( result.errors || [] ).map( escapeTerminalText ).join( '; ' ) || 'unknown error'; + console.log( + `✕ "${ escapeTerminalText( worker.manifest.name ) }" is invalid: ${ errors }` + ); + } else { + const phases = ( result?.phases || [] ).map( escapeTerminalText ).join( ', ' ) || 'none'; + console.log( + `✓ "${ escapeTerminalText( worker.manifest.name ) }" is valid (phases: ${ phases })` + ); + } + } + + if ( invalidCount > 0 ) { + throw new Error( `${ invalidCount } worker(s) failed validation.` ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_success', { + count: workers.length, + invalid: invalidCount, + } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_validate_command_error', { + name, + error: 'validate_failed', + } ); + exit.withError( `Failed to validate edge worker: ${ escapeTerminalText( err.message ) }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage, +} ) + .option( 'path', 'Path to the edge-workers project. Defaults to auto-discovery.' ) + .option( 'all', 'Validate every worker in the project.', false ) + .option( 'skip-build', 'Validate a previously compiled artifact without recompiling.', false ) + .examples( examples ) + .argv( process.argv, edgeWorkersValidateCommand ); diff --git a/src/bin/vip-edge-workers.js b/src/bin/vip-edge-workers.js new file mode 100644 index 000000000..2967eba23 --- /dev/null +++ b/src/bin/vip-edge-workers.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; + +command( { + requiredArgs: 0, +} ) + .command( 'init', 'Scaffold a new edge-workers project.' ) + .command( 'new', 'Add a new worker to an edge-workers project.' ) + .command( 'build', 'Compile worker(s) to WebAssembly locally.' ) + .command( 'validate', 'Validate worker(s) against an environment without deploying.' ) + .command( 'list', 'List the edge workers deployed to an environment.' ) + .command( 'get', 'Retrieve details for a single deployed edge worker.' ) + .command( 'deploy', 'Compile and deploy a worker to an environment.' ) + .command( 'enable', 'Enable a deployed edge worker.' ) + .command( 'disable', 'Disable a deployed edge worker.' ) + .command( 'delete', 'Permanently delete a deployed edge worker.' ) + .argv( process.argv ); diff --git a/src/bin/vip.js b/src/bin/vip.js index 713f7eaa8..a54b70486 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -65,6 +65,7 @@ const runCmd = async function () { .command( 'cache', 'Manage page cache for an environment.' ) .command( 'config', 'Manage environment configurations.' ) .command( 'dev-env', 'Create and manage VIP Local Development Environments.' ) + .command( 'edge-workers', 'Scaffold, compile, and deploy WASM edge workers.' ) .command( 'export', 'Export a copy of data associated with an environment.' ) .command( 'import', 'Import media or SQL database files to an environment.' ) .command( 'logs', 'Retrieve Runtime Logs from an environment.' ) diff --git a/src/lib/api.ts b/src/lib/api.ts index aebb91621..cc8106915 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,7 @@ import debugLib from 'debug'; import { Kind, OperationTypeNode } from 'graphql'; import { API_URL } from './api/constants'; +import { safeGraphQLErrorDebugInfo } from './api/error-debug'; import http from './api/http'; // Config — re-exported from ./api/constants so modules in the rechallenge tree @@ -88,7 +89,7 @@ export default function API( { silenceAuthErrors?: boolean; customRetryLink?: RetryLink; } = {} ): ApolloClient { - const errorLink = new ErrorLink( ( { error } ) => { + const errorLink = new ErrorLink( ( { error, operation } ) => { if ( ! silenceAuthErrors && error instanceof ServerError && error.statusCode === 401 ) { let message; try { @@ -112,6 +113,15 @@ export default function API( { } if ( CombinedGraphQLErrors.is( error ) && globalGraphQLErrorHandlingEnabled ) { + debug( + 'GraphQL errors in response: %s', + JSON.stringify( + safeGraphQLErrorDebugInfo( operation.operationName ?? '', error.errors ), + null, + 2 + ) + ); + for ( const err of error.errors ) { console.error( chalk.red( 'Error:' ), err.message ); } diff --git a/src/lib/api/edge-workers.ts b/src/lib/api/edge-workers.ts new file mode 100644 index 000000000..8697eabd6 --- /dev/null +++ b/src/lib/api/edge-workers.ts @@ -0,0 +1,281 @@ +/** + * GraphQL access for edge workers. + * + * The schema exposes workers under `app.environments(id:).edgeWorkers`, with + * `source` as an on-demand read field, plus create/update/setActive/delete mutations + * keyed by `environmentId`. Worker names are unique per environment, so the CLI + * reconciles create-vs-update by matching on `name`. + * + * NOTE: these types are hand-written rather than codegen'd because the edge + * worker schema is not part of the public schema bundle the codegen runs against. + */ + +import gql from 'graphql-tag'; + +import API from '../../lib/api'; +import UserError from '../user-error'; + +import type { + EdgeWorker, + EdgeWorkerLocation, + EdgeWorkerOnFailure, + EdgeWorkerPhase, +} from '../edge-workers/types'; + +// Selector used by command.js for app/env context resolution. +export const appQuery = ` + id + name + environments { + id + appId + name + type + primaryDomain { + name + } + } +`; + +const EDGE_WORKER_FIELDS = ` + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +`; + +interface EnvironmentWithWorkers { + id: number; + edgeWorkers: EdgeWorker[]; +} + +interface EdgeWorkersQueryResult { + app: { + environments: EnvironmentWithWorkers[]; + } | null; +} + +function isObject( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +function invalidReadResponse(): never { + throw new UserError( 'EdgeWorkers query returned an invalid response.' ); +} + +/** + * Extract the target environment's workers from a `environments(id:)`-filtered + * response, failing closed (UserError) on any malformed shape. The query filters + * by id server-side, so we take the single returned environment. + */ +function pickEnvWorkers( result: EdgeWorkersQueryResult | undefined ): EdgeWorker[] { + if ( ! isObject( result ) || ! isObject( result.app ) ) { + return invalidReadResponse(); + } + const environments = result.app.environments; + if ( ! Array.isArray( environments ) ) { + return invalidReadResponse(); + } + const env = environments[ 0 ]; + if ( ! isObject( env ) || ! Array.isArray( env.edgeWorkers ) ) { + return invalidReadResponse(); + } + return env.edgeWorkers; +} + +function requireMutationPayload< T >( operation: string, value: T | null | undefined ): T { + if ( value === null || value === undefined ) { + throw new UserError( `${ operation } returned no result.` ); + } + return value; +} + +/** List the edge workers deployed to an environment without source. */ +export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { + const api = API( { exitOnError: false } ); + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkers($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { + ${ EDGE_WORKER_FIELDS } + } + } + } + } + `, + variables: { appId, envId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data ); +} + +/** + * Fetch a single worker by name. The schema has no single-worker query, so this + * requests the target environment's workers and matches by name. Source is + * fetched only when explicitly requested. + */ +export async function getEdgeWorker( + appId: number, + envId: number, + name: string, + options: { includeSource?: boolean } = {} +): Promise< EdgeWorker | null > { + const api = API( { exitOnError: false } ); + const fields = + options.includeSource === true ? `${ EDGE_WORKER_FIELDS }\nsource` : EDGE_WORKER_FIELDS; + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkerDetail($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { + ${ fields } + } + } + } + } + `, + variables: { appId, envId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data ).find( worker => worker.name === name ) ?? null; +} + +/** Find a deployed worker by name, or null. Used to reconcile create-vs-update. */ +export async function findEdgeWorkerByName( + appId: number, + envId: number, + name: string +): Promise< EdgeWorker | null > { + const workers = await listEdgeWorkers( appId, envId ); + return workers.find( worker => worker.name === name ) ?? null; +} + +export interface EdgeWorkerWriteInput { + name?: string; + wasmBinary?: string; + location?: EdgeWorkerLocation | null; + onFailure?: EdgeWorkerOnFailure; + source?: string; +} + +export async function createEdgeWorker( + envId: number, + input: EdgeWorkerWriteInput & { name: string; wasmBinary: string } +): Promise< EdgeWorker > { + const api = API( { exitOnError: false } ); + const response = await api.mutate< { createEdgeWorker: EdgeWorker | null } >( { + mutation: gql` + mutation CreateEdgeWorker($input: CreateEdgeWorkerInput!) { + createEdgeWorker(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, ...input } }, + } ); + + return requireMutationPayload( 'createEdgeWorker', response.data?.createEdgeWorker ); +} + +export async function updateEdgeWorker( + envId: number, + edgeWorkerId: number, + input: EdgeWorkerWriteInput +): Promise< EdgeWorker > { + const api = API( { exitOnError: false } ); + const response = await api.mutate< { updateEdgeWorker: EdgeWorker | null } >( { + mutation: gql` + mutation UpdateEdgeWorker($input: UpdateEdgeWorkerInput!) { + updateEdgeWorker(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, edgeWorkerId, ...input } }, + } ); + + return requireMutationPayload( 'updateEdgeWorker', response.data?.updateEdgeWorker ); +} + +export interface EdgeWorkerValidationResult { + valid: boolean; + phases: EdgeWorkerPhase[]; + errors: string[]; +} + +/** + * Server-side dry-run validation of a compiled worker. Persists nothing — used + * to fail fast before the real create/update upload. + */ +export async function validateEdgeWorker( + envId: number, + wasmBinary: string +): Promise< EdgeWorkerValidationResult > { + const api = API( { exitOnError: false } ); + const response = await api.mutate< { + validateEdgeWorker: EdgeWorkerValidationResult | null; + } >( { + mutation: gql` + mutation ValidateEdgeWorker($input: ValidateEdgeWorkerInput!) { + validateEdgeWorker(input: $input) { + valid + phases + errors + } + } + `, + variables: { input: { environmentId: envId, wasmBinary } }, + } ); + + return requireMutationPayload( 'validateEdgeWorker', response.data?.validateEdgeWorker ); +} + +export async function setEdgeWorkerActive( + envId: number, + edgeWorkerId: number, + active: boolean +): Promise< EdgeWorker > { + const api = API( { exitOnError: false } ); + const response = await api.mutate< { setEdgeWorkerActive: EdgeWorker | null } >( { + mutation: gql` + mutation SetEdgeWorkerActive($input: SetEdgeWorkerActiveInput!) { + setEdgeWorkerActive(input: $input) { + ${ EDGE_WORKER_FIELDS } + } + } + `, + variables: { input: { environmentId: envId, edgeWorkerId, active } }, + } ); + + return requireMutationPayload( 'setEdgeWorkerActive', response.data?.setEdgeWorkerActive ); +} + +export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< void > { + const api = API( { exitOnError: false } ); + const response = await api.mutate< { deleteEdgeWorker: boolean | null } >( { + mutation: gql` + mutation DeleteEdgeWorker($input: DeleteEdgeWorkerInput!) { + deleteEdgeWorker(input: $input) + } + `, + variables: { input: { environmentId: envId, edgeWorkerId } }, + } ); + + if ( response.data?.deleteEdgeWorker !== true ) { + throw new UserError( 'deleteEdgeWorker did not confirm deletion.' ); + } +} diff --git a/src/lib/api/error-debug.ts b/src/lib/api/error-debug.ts new file mode 100644 index 000000000..fe0ad4705 --- /dev/null +++ b/src/lib/api/error-debug.ts @@ -0,0 +1,17 @@ +export function safeGraphQLErrorDebugInfo( + operation: string, + errors: readonly { + path?: readonly ( string | number )[]; + extensions?: Record< string, unknown >; + }[] +) { + return errors.map( error => { + const code = error.extensions?.code; + + return { + operation, + path: error.path ?? [], + ...( typeof code === 'string' ? { code } : {} ), + }; + } ); +} diff --git a/src/lib/cli/format.ts b/src/lib/cli/format.ts index 4ced099ec..fdbffbb86 100644 --- a/src/lib/cli/format.ts +++ b/src/lib/cli/format.ts @@ -24,7 +24,7 @@ export function formatData( format: OutputFormat ): string { if ( ! data.length ) { - return ''; + return format === 'json' ? '[]' : ''; } switch ( format ) { @@ -32,7 +32,7 @@ export function formatData( return ids( data as Record< string, unknown >[] ); case 'json': - return JSON.stringify( data, null, '\t' ); + return json( data ); case 'csv': return csv( data as Record< string, unknown >[] ); @@ -46,6 +46,22 @@ export function formatData( } } +function escapeControlCharacter( character: string ): string { + const codePoint = character.codePointAt( 0 ); + if ( codePoint === undefined ) { + return ''; + } + return String.raw`\u${ codePoint.toString( 16 ).padStart( 4, '0' ) }`; +} + +function json( data: Record< string, unknown >[] | Tuple[] ): string { + return JSON.stringify( data, null, '\t' ).replace( + // JSON.stringify already escapes C0 controls, but not DEL or C1 controls. + /[\u007f-\u009f]/g, + escapeControlCharacter + ); +} + export function formatEnvironment( environment: string ): string { if ( 'production' === environment.toLowerCase() ) { return chalk.red( environment.toUpperCase() ); diff --git a/src/lib/edge-workers/confirmation.ts b/src/lib/edge-workers/confirmation.ts new file mode 100644 index 000000000..48af5e834 --- /dev/null +++ b/src/lib/edge-workers/confirmation.ts @@ -0,0 +1,95 @@ +import UserError from '../user-error'; +import { escapeTerminalText } from './output'; + +export interface ProductionMutationConfirmationRequest { + action: 'deploy' | 'enable'; + appName: string; + envType: string; + workerNames: readonly string[]; + enableAfterDeploy: boolean; + skipConfirmation: boolean; + nonInteractive: boolean; +} + +export interface EdgeWorkerDeletionConfirmationRequest { + appName: string; + envType: string; + workerName: string; + force: boolean; +} + +export type EdgeWorkerConfirmFunction = ( message: string ) => Promise< boolean >; + +export function isInteractiveEdgeWorkers( options: { nonInteractive?: boolean } ): boolean { + return ( + process.env.VIP_NON_INTERACTIVE !== '1' && + ! options.nonInteractive && + Boolean( process.stdout.isTTY ) + ); +} + +function productionMutationConfirmationMessage( + request: ProductionMutationConfirmationRequest +): string { + const target = `${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }`; + if ( request.action === 'enable' ) { + return `Enable edge worker "${ escapeTerminalText( + request.workerNames[ 0 ] + ) }" on ${ target }?`; + } + + const action = request.enableAfterDeploy ? 'Deploy and enable' : 'Deploy'; + const workerLabel = request.workerNames.length === 1 ? 'edge worker' : 'edge workers'; + const preposition = request.enableAfterDeploy ? 'on' : 'to'; + const workerNames = request.workerNames.map( escapeTerminalText ).join( ', ' ); + + return `${ action } ${ request.workerNames.length } ${ workerLabel } (${ workerNames }) ${ preposition } ${ target }?`; +} + +export async function confirmProductionEdgeWorkerMutation( + request: ProductionMutationConfirmationRequest, + confirmFn: EdgeWorkerConfirmFunction +): Promise< void > { + if ( request.envType !== 'production' || request.skipConfirmation ) { + return; + } + + if ( request.nonInteractive ) { + const action = + request.action === 'deploy' && request.enableAfterDeploy + ? 'deploy and enable' + : request.action; + throw new UserError( + `Refusing to ${ action } edge workers in production without confirmation. ` + + 'Pass --skip-confirmation to proceed non-interactively.' + ); + } + + const message = productionMutationConfirmationMessage( request ); + + if ( ! ( await confirmFn( message ) ) ) { + throw new UserError( 'Command cancelled by user.' ); + } +} + +export async function confirmEdgeWorkerDeletion( + request: EdgeWorkerDeletionConfirmationRequest, + confirmFn: EdgeWorkerConfirmFunction +): Promise< void > { + if ( request.force ) { + return; + } + + const confirmed = await confirmFn( + `Permanently delete edge worker "${ escapeTerminalText( + request.workerName + ) }" from ${ escapeTerminalText( request.appName ) }.${ escapeTerminalText( + request.envType + ) }?` + ); + if ( ! confirmed ) { + throw new UserError( 'Command cancelled by user.' ); + } +} diff --git a/src/lib/edge-workers/deployment.ts b/src/lib/edge-workers/deployment.ts new file mode 100644 index 000000000..0798c5fa8 --- /dev/null +++ b/src/lib/edge-workers/deployment.ts @@ -0,0 +1,285 @@ +import { + createEdgeWorker, + listEdgeWorkers, + setEdgeWorkerActive, + updateEdgeWorker, + validateEdgeWorker, +} from '../api/edge-workers'; +import UserError from '../user-error'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from './index'; +import { escapeTerminalText } from './output'; + +import type { DiscoveredWorker, EdgeWorker, EdgeWorkerLocation, EdgeWorkerPhase } from './types'; +import type { EdgeWorkerWriteInput } from '../api/edge-workers'; + +export interface EdgeWorkerDeploymentPlanItem { + action: 'create' | 'update'; + worker: DiscoveredWorker; + existing: EdgeWorker | null; + artifact: { wasmPath: string; base64: string; sizeBytes: number }; + validation: 'passed' | 'skipped'; + phases: EdgeWorkerPhase[]; + input: EdgeWorkerWriteInput & { name: string; wasmBinary: string }; + currentLocation: EdgeWorkerLocation | null; + proposedLocation: EdgeWorkerLocation | null; + sourceMode: 'store' | 'omit' | 'preserve'; + enableAfterDeploy: boolean; + intendedActive: boolean; +} + +export interface EdgeWorkerDeploymentPlanOptions { + appId: number; + envId: number; + projectDir: string; + workers: readonly DiscoveredWorker[]; + skipBuild: boolean; + skipValidate: boolean; + skipSource: boolean; + enableAfterDeploy: boolean; +} + +export type EdgeWorkerAppliedCallback = ( + item: EdgeWorkerDeploymentPlanItem, + result: EdgeWorker +) => void | Promise< void >; + +export class DeploymentApplyError extends Error { + public readonly appliedNames: string[]; + public readonly failedName: string; + public readonly unappliedNames: string[]; + public readonly stage: 'upload' | 'enable'; + public readonly uploadCompleted: boolean; + public readonly activeAfterUpload: boolean | null; + + constructor( + appliedNames: string[], + failedName: string, + unappliedNames: string[], + cause: unknown, + state: { + stage: 'upload' | 'enable'; + uploadCompleted: boolean; + activeAfterUpload: boolean | null; + } + ) { + super( `Failed to apply edge worker "${ failedName }".`, { cause } ); + this.name = 'DeploymentApplyError'; + this.appliedNames = appliedNames; + this.failedName = failedName; + this.unappliedNames = unappliedNames; + this.stage = state.stage; + this.uploadCompleted = state.uploadCompleted; + this.activeAfterUpload = state.activeAfterUpload; + } +} + +function prepareArtifact( options: EdgeWorkerDeploymentPlanOptions, worker: DiscoveredWorker ) { + if ( options.skipBuild ) { + return readPrebuiltWorker( options.projectDir, worker ); + } + return buildWorker( options.projectDir, worker ); +} + +async function prepareValidation( + options: EdgeWorkerDeploymentPlanOptions, + worker: DiscoveredWorker, + artifact: EdgeWorkerDeploymentPlanItem[ 'artifact' ] +): Promise< Pick< EdgeWorkerDeploymentPlanItem, 'validation' | 'phases' > > { + if ( options.skipValidate ) { + return { validation: 'skipped', phases: [] }; + } + + const result = await validateEdgeWorker( options.envId, artifact.base64 ); + if ( result.valid !== true ) { + const errors = result.errors.join( '; ' ) || 'unknown error'; + throw new UserError( `worker "${ worker.manifest.name }" failed validation: ${ errors }` ); + } + + return { validation: 'passed', phases: result.phases }; +} + +function proposedLocationFor( + worker: DiscoveredWorker, + existing: EdgeWorker | null, + hasLocation: boolean, + currentLocation: EdgeWorkerLocation | null +): EdgeWorkerLocation | null { + if ( ! existing ) { + return worker.manifest.location ?? null; + } + if ( ! hasLocation ) { + return currentLocation; + } + return worker.manifest.location ?? null; +} + +function prepareInput( + worker: DiscoveredWorker, + existing: EdgeWorker | null, + artifact: EdgeWorkerDeploymentPlanItem[ 'artifact' ], + source: string | undefined, + hasLocation: boolean +): EdgeWorkerDeploymentPlanItem[ 'input' ] { + const input: EdgeWorkerDeploymentPlanItem[ 'input' ] = { + name: worker.manifest.name, + wasmBinary: artifact.base64, + }; + if ( worker.manifest.on_failure ) { + input.onFailure = worker.manifest.on_failure; + } + if ( source !== undefined ) { + input.source = source; + } + if ( existing && hasLocation ) { + input.location = worker.manifest.location ?? null; + } else if ( ! existing && worker.manifest.location ) { + input.location = worker.manifest.location; + } + return input; +} + +function sourceModeFor( + skipSource: boolean, + existing: EdgeWorker | null +): EdgeWorkerDeploymentPlanItem[ 'sourceMode' ] { + if ( ! skipSource ) { + return 'store'; + } + return existing ? 'preserve' : 'omit'; +} + +async function preparePlanItem( + options: EdgeWorkerDeploymentPlanOptions, + worker: DiscoveredWorker, + existing: EdgeWorker | null +): Promise< EdgeWorkerDeploymentPlanItem > { + const artifact = prepareArtifact( options, worker ); + const { validation, phases } = await prepareValidation( options, worker, artifact ); + const source = options.skipSource ? undefined : readWorkerSource( worker ); + const hasLocation = Object.hasOwn( worker.manifest, 'location' ); + const currentLocation = existing?.location ?? null; + const intendedActive = options.enableAfterDeploy || Boolean( existing?.active ); + + return { + action: existing ? 'update' : 'create', + worker, + existing, + artifact, + validation, + phases, + input: prepareInput( worker, existing, artifact, source, hasLocation ), + currentLocation, + proposedLocation: proposedLocationFor( worker, existing, hasLocation, currentLocation ), + sourceMode: sourceModeFor( options.skipSource, existing ), + enableAfterDeploy: options.enableAfterDeploy, + intendedActive, + }; +} + +export async function prepareEdgeWorkerDeploymentPlan( + options: EdgeWorkerDeploymentPlanOptions +): Promise< EdgeWorkerDeploymentPlanItem[] > { + const remoteWorkers = await listEdgeWorkers( options.appId, options.envId ); + const remoteWorkersByName = new Map( remoteWorkers.map( worker => [ worker.name, worker ] ) ); + const items: EdgeWorkerDeploymentPlanItem[] = []; + + for ( const worker of options.workers ) { + const existing = remoteWorkersByName.get( worker.manifest.name ) ?? null; + // eslint-disable-next-line no-await-in-loop + items.push( await preparePlanItem( options, worker, existing ) ); + } + + return items; +} + +function formatLocation( location: EdgeWorkerLocation | null ): string { + return location + ? `${ escapeTerminalText( location.operator ) } "${ escapeTerminalText( location.value ) }"` + : 'all requests'; +} + +function currentActiveLabel( item: EdgeWorkerDeploymentPlanItem ): string { + if ( ! item.existing ) { + return 'new'; + } + return item.existing.active ? 'active' : 'inactive'; +} + +export function deploymentPlanRows( + items: readonly EdgeWorkerDeploymentPlanItem[] +): Record< string, string >[] { + return items.map( item => ( { + worker: escapeTerminalText( item.worker.manifest.name ), + action: item.action, + current_active: currentActiveLabel( item ), + final_active: item.intendedActive ? 'active' : 'inactive', + current_scope: formatLocation( item.currentLocation ), + proposed_scope: formatLocation( item.proposedLocation ), + validation: item.validation, + phases: item.phases.map( escapeTerminalText ).join( ', ' ) || 'none', + bytes: String( item.artifact.sizeBytes ), + source: item.sourceMode, + } ) ); +} + +async function applyPlanItem( + envId: number, + item: EdgeWorkerDeploymentPlanItem +): Promise< EdgeWorker > { + if ( item.action === 'create' ) { + return createEdgeWorker( envId, item.input ); + } + if ( ! item.existing ) { + throw new Error( `Update plan for "${ item.worker.manifest.name }" has no existing worker.` ); + } + return updateEdgeWorker( envId, item.existing.id, item.input ); +} + +export async function applyEdgeWorkerDeploymentPlan( + envId: number, + items: readonly EdgeWorkerDeploymentPlanItem[], + onApplied: EdgeWorkerAppliedCallback +): Promise< void > { + const appliedNames: string[] = []; + + for ( const [ index, item ] of items.entries() ) { + const name = item.worker.manifest.name; + let result: EdgeWorker; + try { + // eslint-disable-next-line no-await-in-loop + result = await applyPlanItem( envId, item ); + } catch ( cause ) { + throw new DeploymentApplyError( + [ ...appliedNames ], + name, + items.slice( index + 1 ).map( remaining => remaining.worker.manifest.name ), + cause, + { stage: 'upload', uploadCompleted: false, activeAfterUpload: null } + ); + } + + let finalResult = result; + if ( item.enableAfterDeploy && ! result.active ) { + try { + // eslint-disable-next-line no-await-in-loop + finalResult = await setEdgeWorkerActive( envId, result.id, true ); + } catch ( cause ) { + throw new DeploymentApplyError( + [ ...appliedNames ], + name, + items.slice( index + 1 ).map( remaining => remaining.worker.manifest.name ), + cause, + { + stage: 'enable', + uploadCompleted: true, + activeAfterUpload: result.active, + } + ); + } + } + + appliedNames.push( name ); + // eslint-disable-next-line no-await-in-loop + await onApplied( item, finalResult ); + } +} diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts new file mode 100644 index 000000000..07efeaf94 --- /dev/null +++ b/src/lib/edge-workers/index.ts @@ -0,0 +1,86 @@ +/** + * Convenience entry point for the edge-workers lib: ties project resolution and + * the toolchain together to produce a deployable artifact. + */ + +import fs from 'node:fs'; + +import UserError from '../user-error'; +import { BUILD_DIR, readProjectDescriptor } from './project'; +import { getToolchain } from './toolchains'; +import { resolveExistingPathWithin, resolvePathWithin, validateWorkerName } from './validation'; + +import type { DiscoveredWorker } from './types'; + +export * from './types'; +export * from './project'; +export * from './location'; +export { getToolchain } from './toolchains'; +export * from './validation'; + +interface BuiltArtifact { + wasmPath: string; + base64: string; + sizeBytes: number; +} + +function encodeArtifact( wasmPath: string ): BuiltArtifact { + const buffer = fs.readFileSync( wasmPath ); + return { wasmPath, base64: buffer.toString( 'base64' ), sizeBytes: buffer.length }; +} + +/** Read a previously compiled artifact without recompiling (used by `deploy --skip-build`). */ +export function readPrebuiltWorker( projectDir: string, worker: DiscoveredWorker ): BuiltArtifact { + const name = validateWorkerName( worker.manifest.name ); + const buildRoot = resolvePathWithin( projectDir, BUILD_DIR, 'Worker build directory' ); + const candidate = resolvePathWithin( buildRoot, `${ name }.wasm`, 'Worker build artifact' ); + if ( ! fs.existsSync( candidate ) ) { + throw new UserError( + `No compiled artifact found for "${ worker.manifest.name }" at "${ candidate }". ` + + 'Run `vip edge-workers build` first, or deploy without `--skip-build`.' + ); + } + if ( fs.lstatSync( buildRoot ).isSymbolicLink() ) { + throw new UserError( 'Worker build directory must not be a symbolic link.' ); + } + if ( fs.lstatSync( candidate ).isSymbolicLink() ) { + throw new UserError( 'Worker build artifact must not be a symbolic link.' ); + } + const canonicalBuildRoot = resolveExistingPathWithin( + projectDir, + BUILD_DIR, + 'Worker build directory' + ); + const wasmPath = resolveExistingPathWithin( + canonicalBuildRoot, + `${ name }.wasm`, + 'Worker build artifact' + ); + + return encodeArtifact( wasmPath ); +} + +/** Compile a worker and return both the artifact path and its base64 encoding. */ +export function buildWorker( projectDir: string, worker: DiscoveredWorker ): BuiltArtifact { + const descriptor = readProjectDescriptor( projectDir ); + const toolchain = getToolchain( descriptor.type ); + + toolchain.ensureAvailable( projectDir ); + const wasmPath = toolchain.compile( projectDir, worker ); + + return encodeArtifact( wasmPath ); +} + +/** Read the entry source of a worker, for storing alongside the binary. */ +export function readWorkerSource( worker: DiscoveredWorker ): string { + const candidate = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + if ( ! fs.existsSync( candidate ) ) { + throw new UserError( `Could not read worker source at "${ candidate }".` ); + } + const entry = resolveExistingPathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + try { + return fs.readFileSync( entry, 'utf8' ); + } catch { + throw new UserError( `Could not read worker source at "${ entry }".` ); + } +} diff --git a/src/lib/edge-workers/location.ts b/src/lib/edge-workers/location.ts new file mode 100644 index 000000000..38721438d --- /dev/null +++ b/src/lib/edge-workers/location.ts @@ -0,0 +1,40 @@ +/** + * Parsing for location rules passed on the command line as + * `:` (e.g. `starts_with:/api/`). A location scopes which + * request paths a worker runs on; workers without one run on all requests. + */ + +import UserError from '../user-error'; +import { hasTerminalControlCharacters } from './output'; +import { EDGE_WORKER_LOCATION_OPERATORS } from './types'; + +import type { EdgeWorkerLocation, EdgeWorkerLocationOperator } from './types'; + +export function parseLocationOption( raw: string ): EdgeWorkerLocation { + // `--location` passed without a value arrives as a boolean, not a string; + // guard so we surface a clear error instead of a TypeError from `.indexOf()`. + if ( typeof raw !== 'string' ) { + throw new UserError( + 'The --location flag requires a value in the form ":" ' + + `(e.g. "starts_with:/api/"). Operators: ${ EDGE_WORKER_LOCATION_OPERATORS.join( ', ' ) }.` + ); + } + + // Split on the first colon only: the value may itself contain colons. + const separator = raw.indexOf( ':' ); + const operator = separator > 0 ? raw.slice( 0, separator ) : ''; + const value = separator > 0 ? raw.slice( separator + 1 ) : ''; + + if ( + ! ( EDGE_WORKER_LOCATION_OPERATORS as string[] ).includes( operator ) || + ! value || + hasTerminalControlCharacters( value ) + ) { + throw new UserError( + `Invalid location "${ raw }". Use ":", where is one of: ` + + `${ EDGE_WORKER_LOCATION_OPERATORS.join( ', ' ) } (e.g. "starts_with:/api/").` + ); + } + + return { operator: operator as EdgeWorkerLocationOperator, value }; +} diff --git a/src/lib/edge-workers/output.ts b/src/lib/edge-workers/output.ts new file mode 100644 index 000000000..0d179709f --- /dev/null +++ b/src/lib/edge-workers/output.ts @@ -0,0 +1,22 @@ +// C0, DEL, and C1 controls can alter terminal state or forge surrounding output. +// eslint-disable-next-line no-control-regex +const TERMINAL_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; +// eslint-disable-next-line no-control-regex +const TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; + +function escapeControlCharacter( character: string ): string { + const codePoint = character.codePointAt( 0 ); + if ( codePoint === undefined ) { + return ''; + } + return String.raw`\u${ codePoint.toString( 16 ).padStart( 4, '0' ) }`; +} + +export function hasTerminalControlCharacters( value: string ): boolean { + return TERMINAL_CONTROL_CHARACTER.test( value ); +} + +/** Render untrusted text without allowing it to emit terminal control characters. */ +export function escapeTerminalText( value: unknown ): string { + return String( value ).replace( TERMINAL_CONTROL_CHARACTERS, escapeControlCharacter ); +} diff --git a/src/lib/edge-workers/project.ts b/src/lib/edge-workers/project.ts new file mode 100644 index 000000000..8a1144280 --- /dev/null +++ b/src/lib/edge-workers/project.ts @@ -0,0 +1,206 @@ +/** + * Edge-workers project resolution and on-disk layout helpers. + * + * Layout (created by `vip edge-workers init`): + * + * edge-workers/ + * edge-workers.json <- project descriptor (toolchain type) + * package.json + * lib/ <- shared modules + * workers/ + * / + * worker.json <- per-worker manifest + * assembly/index.ts <- entry (toolchain-specific) + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import UserError from '../user-error'; +import { parseProjectDescriptor, parseWorkerManifest } from './validation'; + +import type { DiscoveredWorker, ProjectDescriptor, WorkerManifest } from './types'; + +export const PROJECT_DESCRIPTOR_FILE = 'edge-workers.json'; +export const WORKER_MANIFEST_FILE = 'worker.json'; +export const WORKERS_DIR = 'workers'; +/** Conventional output directory for compiled artifacts, relative to the project root. */ +export const BUILD_DIR = 'build'; +/** Conventional subfolder checked when resolving from a site-repo root. */ +export const CONVENTIONAL_PROJECT_DIR = 'edge-workers'; + +function isProjectRoot( dir: string ): boolean { + return fs.existsSync( path.join( dir, PROJECT_DESCRIPTOR_FILE ) ); +} + +/** + * Read a project file as UTF-8, rejecting symlinks before opening so a symlinked + * descriptor/manifest can't redirect the read outside the project tree. + */ +function readProjectFile( file: string, label: string ): string { + let stat: fs.Stats; + try { + stat = fs.lstatSync( file ); + } catch { + throw new UserError( `Could not read ${ label } at "${ file }".` ); + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } at "${ file }" must not be a symbolic link.` ); + } + try { + return fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read ${ label } at "${ file }".` ); + } +} + +/** + * Resolve the edge-workers project directory for a command. + * + * Resolution order: + * 1. `--path` if provided (must contain a project descriptor). + * 2. Walk up from the current working directory looking for the descriptor. + * 3. The conventional `./edge-workers` subfolder, if present. + * 4. Otherwise throw a UserError with guidance. + */ +export function resolveProjectDir( + opts: { path?: string } = {}, + cwd: string = process.cwd() +): string { + if ( opts.path !== undefined ) { + // `--path` passed without a value arrives as a boolean, not a string; + // guard so we surface a clear error instead of a throw from path.resolve(). + if ( typeof opts.path !== 'string' || opts.path === '' ) { + throw new UserError( 'The --path flag requires a path to the edge-workers project.' ); + } + + const explicit = path.resolve( cwd, opts.path ); + if ( ! isProjectRoot( explicit ) ) { + throw new UserError( + `No edge-workers project found at "${ explicit }" (missing ${ PROJECT_DESCRIPTOR_FILE }).` + ); + } + + return explicit; + } + + // Walk up from cwd. + let current = path.resolve( cwd ); + + while ( true ) { + if ( isProjectRoot( current ) ) { + return current; + } + + const parent = path.dirname( current ); + if ( parent === current ) { + break; + } + current = parent; + } + + // Conventional subfolder fallback. + const conventional = path.resolve( cwd, CONVENTIONAL_PROJECT_DIR ); + if ( isProjectRoot( conventional ) ) { + return conventional; + } + + throw new UserError( + 'No edge-workers project found here. Run `vip edge-workers init` to create one, ' + + 'run the command from inside a project, or pass `--path` to point at one.' + ); +} + +export function readProjectDescriptor( projectDir: string ): ProjectDescriptor { + const file = path.join( projectDir, PROJECT_DESCRIPTOR_FILE ); + const raw = readProjectFile( file, 'project descriptor' ); + + let parsed: unknown; + try { + parsed = JSON.parse( raw ) as unknown; + } catch { + throw new UserError( `Project descriptor at "${ file }" is not valid JSON.` ); + } + + return parseProjectDescriptor( parsed, file ); +} + +export function writeProjectDescriptor( projectDir: string, descriptor: ProjectDescriptor ): void { + const file = path.join( projectDir, PROJECT_DESCRIPTOR_FILE ); + fs.mkdirSync( projectDir, { recursive: true } ); + fs.writeFileSync( file, JSON.stringify( descriptor, null, '\t' ) + '\n' ); +} + +export function readWorkerManifest( workerDir: string ): WorkerManifest { + const file = path.join( workerDir, WORKER_MANIFEST_FILE ); + const raw = readProjectFile( file, 'worker manifest' ); + + let parsed: unknown; + try { + parsed = JSON.parse( raw ) as unknown; + } catch { + throw new UserError( `Worker manifest at "${ file }" is not valid JSON.` ); + } + + return parseWorkerManifest( parsed, file ); +} + +export function writeWorkerManifest( workerDir: string, manifest: WorkerManifest ): void { + const file = path.join( workerDir, WORKER_MANIFEST_FILE ); + fs.mkdirSync( workerDir, { recursive: true } ); + fs.writeFileSync( file, JSON.stringify( manifest, null, '\t' ) + '\n' ); +} + +/** Discover all workers in a project by scanning each `workers//worker.json`. */ +export function discoverWorkers( projectDir: string ): DiscoveredWorker[] { + const workersRoot = path.join( projectDir, WORKERS_DIR ); + if ( ! fs.existsSync( workersRoot ) ) { + return []; + } + + const entries = fs.readdirSync( workersRoot, { withFileTypes: true } ); + const workers: DiscoveredWorker[] = []; + const workersByName = new Map< string, DiscoveredWorker >(); + for ( const entry of entries ) { + if ( ! entry.isDirectory() ) { + continue; + } + + const dir = path.join( workersRoot, entry.name ); + if ( ! fs.existsSync( path.join( dir, WORKER_MANIFEST_FILE ) ) ) { + continue; + } + + const worker = { dir, manifest: readWorkerManifest( dir ) }; + const normalizedName = worker.manifest.name.toLocaleLowerCase( 'en-US' ); + if ( workersByName.has( normalizedName ) ) { + throw new UserError( + `Duplicate worker name "${ worker.manifest.name }" found in this project.` + ); + } + workersByName.set( normalizedName, worker ); + workers.push( worker ); + } + + return workers.sort( ( left, right ) => left.manifest.name.localeCompare( right.manifest.name ) ); +} + +/** + * Find a single worker by name (the manifest `name`, falling back to the + * directory name for convenience). + */ +export function findWorker( projectDir: string, name: string ): DiscoveredWorker { + const workers = discoverWorkers( projectDir ); + const match = + workers.find( worker => worker.manifest.name === name ) ?? + workers.find( worker => path.basename( worker.dir ) === name ); + + if ( ! match ) { + const available = workers.map( worker => worker.manifest.name ).join( ', ' ) || '(none)'; + throw new UserError( + `No worker named "${ name }" found in this project. Available workers: ${ available }.` + ); + } + + return match; +} diff --git a/src/lib/edge-workers/toolchains/assemblyscript/constants.ts b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts new file mode 100644 index 000000000..330ffe80c --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts @@ -0,0 +1,10 @@ +/** + * Shared constants for the AssemblyScript toolchain. Kept in one place so a + * version bump or an SDK rename is a single-line change that flows into both the + * scaffolded templates and the scaffold/compile logic. + */ + +export const SDK_PACKAGE = '@automattic/vip-edge-workers-sdk'; +export const SDK_VERSION = '0.3.2'; +export const ASSEMBLYSCRIPT_VERSION = '0.27.0'; +export const DEFAULT_ENTRY = 'assembly/index.ts'; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/index.ts b/src/lib/edge-workers/toolchains/assemblyscript/index.ts new file mode 100644 index 000000000..f246de346 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -0,0 +1,170 @@ +/** + * AssemblyScript toolchain: scaffolds an AssemblyScript edge-workers project, + * adds workers, and compiles them to `.wasm` with the canonical `asc` flags. + * + * The compile flags are a contract with the platform's WASM validator, so the + * CLI owns them here rather than relying on user-authored build scripts — every + * customer then compiles identically and a CLI update can fix everyone at once. + * + * The scaffolded file contents live in `./templates`; shared constants (versions, + * SDK name, paths) live in `./constants`. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; +import { GITIGNORE, PACKAGE_JSON, README, starterWorker, TSCONFIG_JSON } from './templates'; +import UserError from '../../../user-error'; +import { BUILD_DIR, WORKERS_DIR, writeProjectDescriptor, writeWorkerManifest } from '../../project'; +import { + resolveExistingPathWithin, + resolveOutputPathWithin, + resolvePathWithin, + validateWorkerName, +} from '../../validation'; + +import type { DiscoveredWorker } from '../../types'; +import type { Toolchain } from '../index'; + +/** Write `contents` to `filePath`, creating any missing parent directories. */ +function writeFileEnsuringDir( filePath: string, contents: string ): void { + fs.mkdirSync( path.dirname( filePath ), { recursive: true } ); + fs.writeFileSync( filePath, contents ); +} + +function assertScaffoldTargetAvailable( projectDir: string ): void { + if ( ! fs.existsSync( projectDir ) ) return; + // lstatSync (not statSync) so a symlink to a directory is rejected rather than followed. + if ( ! fs.lstatSync( projectDir ).isDirectory() ) { + throw new UserError( + `Cannot create an edge-workers project at "${ projectDir }": target is not a directory.` + ); + } + if ( fs.readdirSync( projectDir ).length > 0 ) { + throw new UserError( + `Cannot create an edge-workers project at "${ projectDir }": target is not empty.` + ); + } +} + +function ascBinaryPath( projectDir: string ): string { + const binName = process.platform === 'win32' ? 'asc.cmd' : 'asc'; + return path.join( projectDir, 'node_modules', '.bin', binName ); +} + +const toolchain: Toolchain = { + type: 'assemblyscript', + + scaffoldProject( projectDir: string ): void { + assertScaffoldTargetAvailable( projectDir ); + + // Every write below ensures its own parent directory, so no standalone + // mkdir is needed up front. + writeProjectDescriptor( projectDir, { + type: 'assemblyscript', + sdk: `${ SDK_PACKAGE }@${ SDK_VERSION }`, + } ); + writeFileEnsuringDir( + path.join( projectDir, 'package.json' ), + JSON.stringify( PACKAGE_JSON, null, '\t' ) + '\n' + ); + writeFileEnsuringDir( + path.join( projectDir, 'tsconfig.json' ), + JSON.stringify( TSCONFIG_JSON, null, '\t' ) + '\n' + ); + writeFileEnsuringDir( path.join( projectDir, '.gitignore' ), GITIGNORE ); + writeFileEnsuringDir( path.join( projectDir, 'README.md' ), README ); + // Keep the workers directory present (and committed) even when empty. + writeFileEnsuringDir( path.join( projectDir, WORKERS_DIR, '.gitkeep' ), '' ); + }, + + scaffoldWorker( projectDir: string, name: string ): void { + validateWorkerName( name ); + const workerDir = path.join( projectDir, WORKERS_DIR, name ); + if ( fs.existsSync( workerDir ) ) { + throw new UserError( `A worker directory already exists at "${ workerDir }".` ); + } + + writeWorkerManifest( workerDir, { name, entry: DEFAULT_ENTRY } ); + writeFileEnsuringDir( path.join( workerDir, DEFAULT_ENTRY ), starterWorker() ); + }, + + ensureAvailable( projectDir: string ): void { + const asc = ascBinaryPath( projectDir ); + if ( ! fs.existsSync( asc ) ) { + throw new UserError( + `The AssemblyScript compiler was not found at "${ asc }". ` + + `Run \`npm install\` in "${ projectDir }" first.` + ); + } + }, + + compile( projectDir: string, worker: DiscoveredWorker ): string { + const asc = ascBinaryPath( projectDir ); + const entryCandidate = resolvePathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + if ( ! fs.existsSync( entryCandidate ) ) { + throw new UserError( `Worker entry file not found: "${ entryCandidate }".` ); + } + const entry = resolveExistingPathWithin( worker.dir, worker.manifest.entry, 'Worker entry' ); + + const nodeModules = path.join( projectDir, 'node_modules' ); + const workerName = validateWorkerName( worker.manifest.name ); + const outFile = resolveOutputPathWithin( + projectDir, + path.join( BUILD_DIR, `${ workerName }.wasm` ), + 'Worker build artifact', + 'Worker build directory' + ); + + const args = [ + entry, + '--runtime', + 'stub', + '--path', + nodeModules, + '--outFile', + outFile, + '--optimizeLevel', + '3', + '--shrinkLevel', + '2', + ]; + + // Enable the json-as transform when it's installed so workers can parse JSON. + // The transform lives at the `transform` subpath; the package root has no + // requirable entry, so `--transform json-as` fails to resolve. + if ( fs.existsSync( path.join( nodeModules, 'json-as' ) ) ) { + args.push( '--transform', 'json-as/transform' ); + } + + // asc misbehaves if NODE_OPTIONS is inherited; drop it like the SDK build does. + const env = { ...process.env }; + delete env.NODE_OPTIONS; + + const result = spawnSync( asc, args, { cwd: projectDir, env, encoding: 'utf8' } ); + + if ( result.error ) { + throw new UserError( `Failed to run the AssemblyScript compiler: ${ result.error.message }` ); + } + + if ( result.status !== 0 ) { + const details = ( result.stderr || result.stdout || '' ).trim(); + throw new UserError( + `Compilation failed for worker "${ worker.manifest.name }"${ + details ? `:\n${ details }` : '.' + }` + ); + } + + return resolveOutputPathWithin( + projectDir, + path.join( BUILD_DIR, `${ workerName }.wasm` ), + 'Worker build artifact', + 'Worker build directory' + ); + }, +}; + +export default toolchain; diff --git a/src/lib/edge-workers/toolchains/assemblyscript/templates.ts b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts new file mode 100644 index 000000000..41e9ebef8 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts @@ -0,0 +1,85 @@ +/** + * The files written into a scaffolded AssemblyScript project. Kept out of the + * toolchain logic so the scaffold steps read cleanly; the dynamic bits (SDK + * package name, workers dir, default entry) interpolate from shared constants so + * there's a single source of truth. + */ + +import { ASSEMBLYSCRIPT_VERSION, DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; +import { BUILD_DIR, WORKERS_DIR } from '../../project'; + +export const PACKAGE_JSON = { + name: 'edge-workers', + version: '0.0.0', + private: true, + description: 'VIP edge workers', + type: 'module', + scripts: { + build: 'vip edge-workers build --all', + }, + dependencies: { + [ SDK_PACKAGE ]: SDK_VERSION, + }, + devDependencies: { + assemblyscript: ASSEMBLYSCRIPT_VERSION, + }, +}; + +export const TSCONFIG_JSON = { + extends: 'assemblyscript/std/assembly.json', + include: [ './**/*.ts' ], +}; + +export const GITIGNORE = `node_modules/ +${ BUILD_DIR }/ +`; + +export const README = `# Edge workers + +AssemblyScript edge workers for your VIP environment. Each worker lives in its +own folder under \`${ WORKERS_DIR }/\` and is compiled to a \`.wasm\` binary that +runs at the edge. + +## Getting started + +\`\`\`sh +npm install # install the SDK + compiler +vip edge-workers new my-worker # scaffold a new worker +# edit ${ WORKERS_DIR }/my-worker/${ DEFAULT_ENTRY } +vip @my-site.develop edge-workers deploy my-worker +\`\`\` + +Commit the generated \`package-lock.json\` after \`npm install\` so installs use the +reviewed dependency tree in local development and automation. + +Shared AssemblyScript modules go in \`lib/\` and can be imported from any worker. + +## Parsing JSON + +To work with JSON in a worker, install [json-as](https://www.npmjs.com/package/json-as) +(\`npm install --save-dev json-as@^1.3.4\`); the build enables its compiler +transform automatically when the package is present. +`; + +export function starterWorker(): string { + return `import { Response, onClientResponse } from '${ SDK_PACKAGE }'; + +export { alloc, on_client_response } from '${ SDK_PACKAGE }/assembly/index'; + +// Client response: runs before the response reaches the client. +onClientResponse( ( response: Response ): void => {} ); + +// Other available phases are intentionally inactive. To activate one, add its +// SDK type and hook to the import above, its host entrypoint to the export above, +// and its handler below. Do not export a phase without implementing its hook. +// +// Client request: Request, onClientRequest, on_client_request +// onClientRequest( ( request: Request ): void => {} ); +// +// Origin request: Request, onOriginRequest, on_origin_request +// onOriginRequest( ( request: Request ): void => {} ); +// +// Origin response: Response, onOriginResponse, on_origin_response +// onOriginResponse( ( response: Response ): void => {} ); +`; +} diff --git a/src/lib/edge-workers/toolchains/index.ts b/src/lib/edge-workers/toolchains/index.ts new file mode 100644 index 000000000..3dd28b5ee --- /dev/null +++ b/src/lib/edge-workers/toolchains/index.ts @@ -0,0 +1,56 @@ +/** + * Toolchain registry. + * + * A Toolchain encapsulates everything language-specific about an edge-workers + * project: how to scaffold it, how to add a worker, how to verify the local + * compiler is available, and how to compile a worker to a `.wasm` artifact. + * + * Everything downstream of `compile()` (base64, upload, list, toggle, delete) + * is language-neutral, so adding a new language (e.g. Rust) means implementing + * one Toolchain and registering it here — nothing in the command layer changes. + */ + +import UserError from '../../user-error'; +import { SUPPORTED_EDGE_WORKER_TYPES } from '../types'; +import assemblyscript from './assemblyscript'; + +import type { DiscoveredWorker, EdgeWorkerType } from '../types'; + +export interface Toolchain { + type: EdgeWorkerType; + + /** Scaffold a fresh project at `projectDir`. */ + scaffoldProject( projectDir: string ): void; + + /** Add a new worker named `name` to an existing project. */ + scaffoldWorker( projectDir: string, name: string ): void; + + /** + * Verify the local compiler toolchain is available for this project, + * throwing a UserError with remediation steps if not. + */ + ensureAvailable( projectDir: string ): void; + + /** + * Compile a worker to a `.wasm` binary. Returns the absolute path to the + * produced artifact. + */ + compile( projectDir: string, worker: DiscoveredWorker ): string; +} + +const TOOLCHAINS: Record< EdgeWorkerType, Toolchain > = { + assemblyscript, +}; + +export function getToolchain( type: EdgeWorkerType ): Toolchain { + const toolchain = TOOLCHAINS[ type ]; + if ( ! toolchain ) { + throw new UserError( + `Unknown edge worker type "${ type }". Supported types: ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }.` + ); + } + + return toolchain; +} diff --git a/src/lib/edge-workers/types.ts b/src/lib/edge-workers/types.ts new file mode 100644 index 000000000..c3e9d7e1f --- /dev/null +++ b/src/lib/edge-workers/types.ts @@ -0,0 +1,90 @@ +/** + * Shared types for the edge-workers commands. + * + * The local half of edge workers (scaffold + compile) is language-specific and + * lives behind the Toolchain abstraction; the remote half (upload, list, toggle) + * is language-neutral because the deployable artifact is always a `.wasm` binary. + */ + +/** + * The languages/SDKs an edge-workers project can be scaffolded with. Only + * AssemblyScript is implemented today; new toolchains slot in via the registry + * in `./toolchains` without touching the command layer. + */ +export type EdgeWorkerType = 'assemblyscript'; + +export const SUPPORTED_EDGE_WORKER_TYPES: EdgeWorkerType[] = [ 'assemblyscript' ]; + +export const DEFAULT_EDGE_WORKER_TYPE: EdgeWorkerType = 'assemblyscript'; + +/** The behavior to apply when a worker errors at runtime (mirrors the API enum). */ +export type EdgeWorkerOnFailure = 'continue' | 'error'; + +/** The request/response phases a worker hooks into, derived from its wasm exports (mirrors the API enum). */ +export type EdgeWorkerPhase = + | 'client_request' + | 'client_response' + | 'origin_request' + | 'origin_response'; + +/** The operators available for matching an edge worker's location (mirrors the API enum). */ +export type EdgeWorkerLocationOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with'; + +export const EDGE_WORKER_LOCATION_OPERATORS: EdgeWorkerLocationOperator[] = [ + 'contains', + 'equals', + 'starts_with', + 'ends_with', +]; + +/** A rule scoping which requests a worker runs on. Runs on all requests when absent. */ +export interface EdgeWorkerLocation { + operator: EdgeWorkerLocationOperator; + value: string; +} + +/** + * The project descriptor written once at `init` to the project root + * (`edge-workers.json`). It records which toolchain the project uses so that + * `new`/`build`/`deploy` can dispatch without re-asking. It is intentionally NOT + * a registry of workers — workers are discovered by scanning for `worker.json`. + */ +export interface ProjectDescriptor { + type: EdgeWorkerType; + /** The pinned SDK dependency spec, for reference (e.g. `@automattic/vip-edge-workers-sdk@^0.1.0`). */ + sdk?: string; +} + +/** + * The per-worker manifest (`worker.json`) co-located with each worker's code. + * Holds exactly the metadata the create/update API needs, keyed by `name`. + */ +export interface WorkerManifest { + /** The human-readable name; the per-site unique key used to reconcile create-vs-update. */ + name: string; + /** Entry source file, relative to the worker directory. Defaults per toolchain. */ + entry: string; + location?: EdgeWorkerLocation | null; + on_failure?: EdgeWorkerOnFailure; +} + +/** A worker discovered on disk: its directory plus parsed manifest. */ +export interface DiscoveredWorker { + /** Absolute path to the worker directory. */ + dir: string; + manifest: WorkerManifest; +} + +/** A deployed edge worker as returned by the API. */ +export interface EdgeWorker { + id: number; + name: string; + location: EdgeWorkerLocation | null; + phases: EdgeWorkerPhase[]; + onFailure: EdgeWorkerOnFailure; + active: boolean; + createdAt: string; + updatedAt: string; + /** Only present when explicitly requested (on-demand field). */ + source?: string | null; +} diff --git a/src/lib/edge-workers/validation.ts b/src/lib/edge-workers/validation.ts new file mode 100644 index 000000000..47cd1483b --- /dev/null +++ b/src/lib/edge-workers/validation.ts @@ -0,0 +1,243 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import UserError from '../user-error'; +import { hasTerminalControlCharacters } from './output'; +import { EDGE_WORKER_LOCATION_OPERATORS, SUPPORTED_EDGE_WORKER_TYPES } from './types'; + +import type { + EdgeWorkerLocation, + EdgeWorkerLocationOperator, + EdgeWorkerOnFailure, + EdgeWorkerType, + ProjectDescriptor, + WorkerManifest, +} from './types'; + +// eslint-disable-next-line security/detect-unsafe-regex +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; +// This range deliberately rejects every Windows-disallowed control character. +// eslint-disable-next-line no-control-regex +const INVALID_PATH_CHARACTER = /[<>:"/\\|?*\u0000-\u001f]/; + +export function validateWorkerName( name: unknown, label = 'worker name' ): string { + if ( + typeof name !== 'string' || + name.length === 0 || + name.length > 64 || + name === '.' || + name === '..' || + INVALID_PATH_CHARACTER.test( name ) || + /[. ]$/.test( name ) || + WINDOWS_RESERVED_NAME.test( name ) + ) { + throw new UserError( `Invalid ${ label } "${ String( name ) }".` ); + } + return name; +} + +export function resolvePathWithin( root: string, relativePath: string, label: string ): string { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + path.isAbsolute( relativePath ) + ) { + throw new UserError( `${ label } must be a non-empty relative path.` ); + } + const resolvedRoot = path.resolve( root ); + const resolvedPath = path.resolve( resolvedRoot, relativePath ); + const relative = path.relative( resolvedRoot, resolvedPath ); + if ( + relative === '..' || + relative.startsWith( `..${ path.sep }` ) || + path.isAbsolute( relative ) + ) { + throw new UserError( `${ label } must stay within "${ resolvedRoot }".` ); + } + return resolvedPath; +} + +function isPathWithin( root: string, candidate: string ): boolean { + const relative = path.relative( root, candidate ); + return ( + relative === '' || + ( relative !== '..' && + ! relative.startsWith( `..${ path.sep }` ) && + ! path.isAbsolute( relative ) ) + ); +} + +function realpath( target: string, label: string ): string { + try { + return fs.realpathSync.native( target ); + } catch { + throw new UserError( `${ label } could not be resolved at "${ target }".` ); + } +} + +function lstatIfExists( target: string ): fs.Stats | undefined { + try { + return fs.lstatSync( target ); + } catch ( error ) { + if ( ( error as NodeJS.ErrnoException ).code === 'ENOENT' ) { + return undefined; + } + throw error; + } +} + +function assertCanonicalPathWithin( root: string, candidate: string, label: string ): void { + if ( ! isPathWithin( root, candidate ) ) { + throw new UserError( `${ label } must stay within "${ root }".` ); + } +} + +function ensureOutputDirectory( target: string, label: string ): void { + const stat = lstatIfExists( target ); + if ( ! stat ) { + fs.mkdirSync( target ); + return; + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } must not be a symbolic link.` ); + } + if ( ! stat.isDirectory() ) { + throw new UserError( `${ label } must be a directory.` ); + } +} + +function validateExistingOutput( outputPath: string, canonicalRoot: string, label: string ): void { + const stat = lstatIfExists( outputPath ); + if ( ! stat ) { + return; + } + if ( stat.isSymbolicLink() ) { + throw new UserError( `${ label } must not be a symbolic link.` ); + } + if ( ! stat.isFile() ) { + throw new UserError( `${ label } must be a regular file.` ); + } + assertCanonicalPathWithin( canonicalRoot, realpath( outputPath, label ), label ); +} + +/** Resolve an existing input and require its canonical target to remain below the canonical root. */ +export function resolveExistingPathWithin( + root: string, + relativePath: string, + label: string +): string { + const resolvedPath = resolvePathWithin( root, relativePath, label ); + const canonicalRoot = realpath( path.resolve( root ), `${ label } root` ); + const canonicalPath = realpath( resolvedPath, label ); + + if ( ! isPathWithin( canonicalRoot, canonicalPath ) ) { + throw new UserError( `${ label } must stay within "${ canonicalRoot }".` ); + } + + return canonicalPath; +} + +/** + * Prepare an output path below an existing root. Existing parent components and + * the output itself must not be symlinks. Missing parents are created one at a + * time and then canonicalized below the root before the path is returned. + */ +export function resolveOutputPathWithin( + root: string, + relativePath: string, + label: string, + directoryLabel: string +): string { + const resolvedRoot = path.resolve( root ); + const resolvedPath = resolvePathWithin( resolvedRoot, relativePath, label ); + const canonicalRoot = realpath( resolvedRoot, `${ label } root` ); + const relativeParent = path.relative( resolvedRoot, path.dirname( resolvedPath ) ); + const components = relativeParent === '' ? [] : relativeParent.split( path.sep ); + let current = resolvedRoot; + + for ( const component of components ) { + current = path.join( current, component ); + ensureOutputDirectory( current, directoryLabel ); + assertCanonicalPathWithin( canonicalRoot, realpath( current, directoryLabel ), directoryLabel ); + } + + const canonicalParent = realpath( path.dirname( resolvedPath ), directoryLabel ); + const outputPath = path.join( canonicalParent, path.basename( resolvedPath ) ); + validateExistingOutput( outputPath, canonicalRoot, label ); + + return outputPath; +} + +function isPlainObject( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +export function parseProjectDescriptor( value: unknown, file: string ): ProjectDescriptor { + if ( ! isPlainObject( value ) ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "type" field.` ); + } + if ( value.type === undefined ) { + throw new UserError( `Project descriptor at "${ file }" is missing a "type" field.` ); + } + if ( ! SUPPORTED_EDGE_WORKER_TYPES.includes( value.type as EdgeWorkerType ) ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "type" field.` ); + } + if ( value.sdk !== undefined && typeof value.sdk !== 'string' ) { + throw new UserError( `Project descriptor at "${ file }" has an invalid "sdk" field.` ); + } + + const descriptor: ProjectDescriptor = { type: value.type as EdgeWorkerType }; + if ( value.sdk !== undefined ) { + descriptor.sdk = value.sdk; + } + return descriptor; +} + +function parseLocation( value: unknown, file: string ): EdgeWorkerLocation | null | undefined { + if ( value === undefined || value === null ) { + return value; + } + if ( ! isPlainObject( value ) ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location.` ); + } + if ( ! EDGE_WORKER_LOCATION_OPERATORS.includes( value.operator as EdgeWorkerLocationOperator ) ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location operator.` ); + } + if ( + typeof value.value !== 'string' || + value.value.length === 0 || + hasTerminalControlCharacters( value.value ) + ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid location value.` ); + } + return { operator: value.operator as EdgeWorkerLocationOperator, value: value.value }; +} + +export function parseWorkerManifest( value: unknown, file: string ): WorkerManifest { + if ( ! isPlainObject( value ) ) { + throw new UserError( `Worker manifest at "${ file }" must be an object.` ); + } + + const name = validateWorkerName( value.name, 'worker name' ); + if ( typeof value.entry !== 'string' || value.entry.length === 0 ) { + throw new UserError( `Worker manifest at "${ file }" is missing an "entry" field.` ); + } + resolvePathWithin( path.dirname( file ), value.entry, 'Worker entry' ); + if ( + value.on_failure !== undefined && + value.on_failure !== 'continue' && + value.on_failure !== 'error' + ) { + throw new UserError( `Worker manifest at "${ file }" has an invalid "on_failure" field.` ); + } + + const manifest: WorkerManifest = { name, entry: value.entry }; + const location = parseLocation( value.location, file ); + if ( location !== undefined ) { + manifest.location = location; + } + if ( value.on_failure !== undefined ) { + manifest.on_failure = value.on_failure as EdgeWorkerOnFailure; + } + return manifest; +}