diff --git a/__tests__/bin/vip-edge-workers-deploy.js b/__tests__/bin/vip-edge-workers-deploy.js new file mode 100644 index 000000000..c33856f08 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-deploy.js @@ -0,0 +1,190 @@ +import { edgeWorkersDeployCommand } from '../../src/bin/vip-edge-workers-deploy'; +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'; + +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: '', + findEdgeWorkerByName: jest.fn(), + createEdgeWorker: jest.fn(), + updateEdgeWorker: jest.fn(), + validateEdgeWorker: jest.fn(), +} ) ); + +jest.mock( '../../src/lib/edge-workers', () => ( { + buildWorker: jest.fn(), + readPrebuiltWorker: jest.fn(), + readWorkerSource: 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 }, + skipBuild: true, +}; + +const worker = { + dir: '/proj/workers/my-worker', + manifest: { name: 'my-worker', entry: 'assembly/index.ts', on_failure: 'continue' }, +}; + +describe( 'edgeWorkersDeployCommand()', () => { + beforeEach( () => { + jest.clearAllMocks(); + project.resolveProjectDir.mockReturnValue( '/proj' ); + project.findWorker.mockReturnValue( worker ); + lib.readPrebuiltWorker.mockReturnValue( { + wasmPath: '/proj/build/my-worker.wasm', + base64: 'V0FTTQ==', + sizeBytes: 5, + } ); + lib.readWorkerSource.mockReturnValue( 'source code' ); + api.validateEdgeWorker.mockResolvedValue( { + valid: true, + phases: [ 'client_response' ], + errors: [], + } ); + } ); + + it( 'creates a worker when none exists with that name', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.createEdgeWorker ).toHaveBeenCalledWith( 3, { + name: 'my-worker', + wasmBinary: 'V0FTTQ==', + onFailure: 'continue', + source: 'source code', + } ); + expect( api.updateEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'updates the worker when one already exists with that name', async () => { + api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); + api.updateEdgeWorker.mockResolvedValue( { id: 42, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.updateEdgeWorker ).toHaveBeenCalledWith( 3, 42, { + name: 'my-worker', + wasmBinary: 'V0FTTQ==', + onFailure: 'continue', + source: 'source code', + location: null, + } ); + expect( api.createEdgeWorker ).not.toHaveBeenCalled(); + } ); + + it( 'sends the manifest location on update, clearing it when absent', async () => { + const location = { operator: 'starts_with', value: '/api/' }; + project.findWorker.mockReturnValue( { + ...worker, + manifest: { ...worker.manifest, location }, + } ); + api.findEdgeWorkerByName.mockResolvedValue( { id: 42 } ); + api.updateEdgeWorker.mockResolvedValue( { id: 42, phases: [ 'response' ] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.updateEdgeWorker ).toHaveBeenCalledWith( + 3, + 42, + expect.objectContaining( { location } ) + ); + } ); + + it( 'omits location on create when the manifest has none', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.createEdgeWorker ).toHaveBeenCalledWith( + 3, + expect.not.objectContaining( { location: expect.anything() } ) + ); + } ); + + it( 'omits source when --skip-source is set', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], { ...opts, skipSource: true } ); + + expect( lib.readWorkerSource ).not.toHaveBeenCalled(); + expect( api.createEdgeWorker ).toHaveBeenCalledWith( + 3, + expect.not.objectContaining( { source: expect.anything() } ) + ); + } ); + + it( 'validates against the env before uploading', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], opts ); + + expect( api.validateEdgeWorker ).toHaveBeenCalledWith( 3, 'V0FTTQ==' ); + } ); + + it( 'aborts the upload when validation fails', async () => { + api.validateEdgeWorker.mockResolvedValue( { + valid: false, + phases: [], + errors: [ 'missing alloc export' ], + } ); + + await expect( edgeWorkersDeployCommand( [ 'my-worker' ], opts ) ).rejects.toBe( + 'EXIT_WITH_ERROR' + ); + expect( api.createEdgeWorker ).not.toHaveBeenCalled(); + expect( api.updateEdgeWorker ).not.toHaveBeenCalled(); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'missing alloc export' ) + ); + } ); + + it( 'skips validation when --skip-validate is set', async () => { + api.findEdgeWorkerByName.mockResolvedValue( null ); + api.createEdgeWorker.mockResolvedValue( { id: 7, phases: [] } ); + + await edgeWorkersDeployCommand( [ 'my-worker' ], { ...opts, skipValidate: true } ); + + expect( api.validateEdgeWorker ).not.toHaveBeenCalled(); + expect( api.createEdgeWorker ).toHaveBeenCalled(); + } ); + + it( 'errors when no worker name and no --all is given', async () => { + await expect( edgeWorkersDeployCommand( [], opts ) ).rejects.toBe( 'EXIT_WITH_ERROR' ); + expect( exit.withError ).toHaveBeenCalledWith( + expect.stringContaining( 'supply a worker name' ) + ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-list.js b/__tests__/bin/vip-edge-workers-list.js new file mode 100644 index 000000000..616493d2d --- /dev/null +++ b/__tests__/bin/vip-edge-workers-list.js @@ -0,0 +1,78 @@ +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'; + +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', + }, + ] ); + } ); + + 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( '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' ); + } ); +} ); diff --git a/__tests__/bin/vip-edge-workers-validate.js b/__tests__/bin/vip-edge-workers-validate.js new file mode 100644 index 000000000..f6949f102 --- /dev/null +++ b/__tests__/bin/vip-edge-workers-validate.js @@ -0,0 +1,114 @@ +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'; + +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(); + } ); + + 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' ) ); + } ); + + 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 ).toHaveBeenCalledWith( + expect.stringContaining( 'supply a worker name' ) + ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/location.js b/__tests__/lib/edge-workers/location.js new file mode 100644 index 000000000..3f3da23f9 --- /dev/null +++ b/__tests__/lib/edge-workers/location.js @@ -0,0 +1,21 @@ +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' ); + } + ); +} ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js new file mode 100644 index 000000000..8e9852363 --- /dev/null +++ b/__tests__/lib/edge-workers/project.js @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + CONVENTIONAL_PROJECT_DIR, + discoverWorkers, + findWorker, + readProjectDescriptor, + 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/ ); + } ); + } ); + + 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"/ ); + } ); + } ); + + 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( 'finds a worker by name', () => { + const project = makeProject( path.join( tmp, 'proj' ) ); + makeWorker( project, 'alpha' ); + expect( findWorker( project, 'alpha' ).manifest.name ).toBe( 'alpha' ); + } ); + + 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/ ); + } ); + } ); +} ); diff --git a/__tests__/lib/edge-workers/toolchains.js b/__tests__/lib/edge-workers/toolchains.js new file mode 100644 index 000000000..5d4e8b1cd --- /dev/null +++ b/__tests__/lib/edge-workers/toolchains.js @@ -0,0 +1,72 @@ +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'; + +describe( 'edge-workers toolchains', () => { + let tmp; + + beforeEach( () => { + tmp = fs.mkdtempSync( path.join( os.tmpdir(), 'ew-tc-' ) ); + } ); + + 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 ).toHaveProperty( '@automattic/vip-edge-workers-sdk' ); + expect( pkg.devDependencies ).toHaveProperty( 'assemblyscript' ); + } ); + + it( 'refuses to scaffold over an existing project', () => { + const project = path.join( tmp, 'proj' ); + tc.scaffoldProject( project ); + expect( () => tc.scaffoldProject( project ) ).toThrow( /already exists/ ); + } ); + + 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 ); + } ); + + 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( '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/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6d9377513..0c25f0cc6 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -69,6 +69,10 @@ "vip-config-software-update": "dist/bin/vip-config-software-update.js", "vip-db": "dist/bin/vip-db.js", "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js", + "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", + "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js", + "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", + "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", "vip-dev-env": "dist/bin/vip-dev-env.js", "vip-dev-env-create": "dist/bin/vip-dev-env-create.js", "vip-dev-env-destroy": "dist/bin/vip-dev-env-destroy.js", @@ -92,6 +96,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 d0d69a534..862b6aaea 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..ea09b4a31 --- /dev/null +++ b/src/bin/vip-edge-workers-build.js @@ -0,0 +1,63 @@ +#!/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 { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +import { trackEvent } from '../lib/tracker'; + +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 { + const projectDir = resolveProjectDir( { path: opt.path } ); + + const workers = + name && ! opt.all ? [ findWorker( projectDir, name ) ] : discoverWorkers( projectDir ); + + if ( ! workers.length ) { + exit.withError( '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 "${ worker.manifest.name }" → ${ 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: err.message } ); + exit.withError( 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..41260bbdf --- /dev/null +++ b/src/bin/vip-edge-workers-delete.js @@ -0,0 +1,51 @@ +#!/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 { 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 ) { + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await deleteEdgeWorker( env.id, worker.id ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_success', { name } ); + console.log( `✓ Deleted edge worker "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_delete_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to delete edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + requireConfirm: 'Are you sure you want to permanently delete this edge worker?', + usage, +} ) + .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..fe511dfd2 --- /dev/null +++ b/src/bin/vip-edge-workers-deploy.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import { + appQuery, + createEdgeWorker, + findEdgeWorkerByName, + updateEdgeWorker, + validateEdgeWorker, +} from '../lib/api/edge-workers'; +import command from '../lib/cli/command'; +import * as exit from '../lib/cli/exit'; +import { buildWorker, readPrebuiltWorker, readWorkerSource } from '../lib/edge-workers'; +import { discoverWorkers, findWorker, resolveProjectDir } from '../lib/edge-workers/project'; +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.', + }, +]; + +async function deployWorker( app, env, projectDir, worker, opt ) { + const artifact = opt.skipBuild + ? readPrebuiltWorker( projectDir, worker ) + : buildWorker( projectDir, worker ); + + const { name, location, on_failure: onFailure } = worker.manifest; + + // Server-side dry-run validation before the real upload: persists nothing and + // fails fast with structured errors. The create/update below validates again, + // so `--skip-validate` just trades the early check for a slightly later one. + if ( ! opt.skipValidate ) { + const validation = await validateEdgeWorker( env.id, artifact.base64 ); + if ( validation && ! validation.valid ) { + const errors = ( validation.errors || [] ).join( '; ' ) || 'unknown error'; + throw new Error( `worker "${ name }" failed validation: ${ errors }` ); + } + } + + const source = opt.skipSource ? undefined : readWorkerSource( worker ); + + const existing = await findEdgeWorkerByName( app.id, env.id, name ); + + const input = { + wasmBinary: artifact.base64, + ...( onFailure ? { onFailure } : {} ), + ...( source ? { source } : {} ), + }; + + if ( existing ) { + // Location is always sent on update: null clears the rule, so removing + // `location` from the manifest reverts the worker to running everywhere. + const result = await updateEdgeWorker( env.id, existing.id, { + name, + ...input, + location: location ?? null, + } ); + return { action: 'updated', worker: result, sizeBytes: artifact.sizeBytes }; + } + + const result = await createEdgeWorker( env.id, { + name, + ...input, + ...( location ? { location } : {} ), + } ); + return { action: 'created', worker: result, sizeBytes: artifact.sizeBytes }; +} + +export async function edgeWorkersDeployCommand( args = [], opt = {} ) { + const { app, env } = opt; + const name = args[ 0 ]; + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_execute', { + name, + all: Boolean( opt.all ), + } ); + + try { + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + exit.withError( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + exit.withError( 'Please supply a worker name to deploy, or pass `--all`.' ); + } + + // Deploy sequentially for clear, ordered output and to avoid hammering the API. + for ( const worker of workers ) { + // eslint-disable-next-line no-await-in-loop + const result = await deployWorker( app, env, projectDir, worker, opt ); + const { action, worker: deployed, sizeBytes } = result; + const phases = deployed?.phases; + const phasesNote = phases ? `, phases: ${ phases.join( ', ' ) || 'none' }` : ''; + console.log( + `✓ ${ action } "${ worker.manifest.name }" (${ sizeBytes } bytes${ phasesNote })` + ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_success', { + count: workers.length, + } ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_deploy_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to deploy edge worker: ${ err.message }` ); + } +} + +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 the worker source alongside the binary.', 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..99960ee54 --- /dev/null +++ b/src/bin/vip-edge-workers-disable.js @@ -0,0 +1,50 @@ +#!/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 { 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 ) { + exit.withError( `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 "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_disable_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to disable edge worker: ${ 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..c36162866 --- /dev/null +++ b/src/bin/vip-edge-workers-enable.js @@ -0,0 +1,50 @@ +#!/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 { 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 ) { + exit.withError( `No edge worker named "${ name }" is deployed to this environment.` ); + } + + await setEdgeWorkerActive( env.id, worker.id, true ); + + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_success', { name } ); + console.log( `✓ Enabled edge worker "${ name }".` ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_enable_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to enable edge worker: ${ err.message }` ); + } +} + +command( { + appContext: true, + appQuery, + envContext: true, + requiredArgs: 1, + usage, +} ) + .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..a97e02b1c --- /dev/null +++ b/src/bin/vip-edge-workers-get.js @@ -0,0 +1,85 @@ +#!/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 { 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 ]; + + 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 ); + } catch ( err ) { + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_error', { + name, + error: err.message, + } ); + exit.withError( `Failed to get edge worker: ${ 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 "${ name }" is deployed to this environment.` ); + } + + await trackEventWithEnv( app.id, env.id, 'edge_workers_get_command_success', { name } ); + + const location = worker.location + ? `${ worker.location.operator } "${ worker.location.value }"` + : 'all requests'; + + console.log( + keyValue( [ + { key: 'ID', value: worker.id }, + { key: 'Name', value: worker.name }, + { key: 'Active', value: worker.active ? 'yes' : 'no' }, + { key: 'Phases', value: ( worker.phases || [] ).join( ', ' ) }, + { key: 'Location', value: location }, + { key: 'On failure', value: worker.onFailure }, + { key: 'Created', value: worker.createdAt }, + { key: 'Modified', value: worker.updatedAt }, + ] ) + ); + + if ( opt.source ) { + console.log( '\nSource:' ); + console.log( worker.source ?? '(no source stored)' ); + } +} + +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..7540d7c1c --- /dev/null +++ b/src/bin/vip-edge-workers-init.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 { 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 "${ type }". Supported types: ${ SUPPORTED_EDGE_WORKER_TYPES.join( + ', ' + ) }.` + ); + } + + try { + getToolchain( type ).scaffoldProject( projectDir ); + } catch ( err ) { + await trackEvent( 'edge_workers_init_command_error', { type, error: err.message } ); + exit.withError( err.message ); + } + + await trackEvent( 'edge_workers_init_command_success', { type } ); + + console.log( `✓ Created a new ${ type } edge-workers project in ${ projectDir }` ); + console.log( '\nNext steps:' ); + console.log( ` cd ${ 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..99ed9ef4a --- /dev/null +++ b/src/bin/vip-edge-workers-list.js @@ -0,0 +1,68 @@ +#!/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 { 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 ) { + if ( ! location ) { + return 'all requests'; + } + + return `${ location.operator } "${ 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: err.message, + } ); + exit.withError( `Failed to list edge workers: ${ 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 []; + } + + return workers.map( worker => ( { + id: worker.id, + name: worker.name, + active: worker.active ? 'yes' : 'no', + phases: ( worker.phases || [] ).join( ', ' ), + location: formatLocation( worker.location ), + on_failure: worker.onFailure, + modified: 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..24740f26b --- /dev/null +++ b/src/bin/vip-edge-workers-new.js @@ -0,0 +1,82 @@ +#!/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 { + 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 { 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 { + // Parse up front so a bad --location doesn't leave a half-created worker behind. + const location = opt.location ? 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 "${ name }" in ${ path.join( projectDir, entryDir ) }` ); + console.log( '\nEdit the worker, then deploy it with:' ); + console.log( ` vip @my-site.develop edge-workers deploy ${ name }` ); + } catch ( err ) { + await trackEvent( 'edge_workers_new_command_error', { name, error: err.message } ); + exit.withError( 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..c0f57cc2c --- /dev/null +++ b/src/bin/vip-edge-workers-validate.js @@ -0,0 +1,98 @@ +#!/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 { 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: 'Compile a worker and validate it against the environment without deploying.', + }, + { + 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 { + const projectDir = resolveProjectDir( { path: opt.path } ); + + let workers; + if ( opt.all ) { + workers = discoverWorkers( projectDir ); + if ( ! workers.length ) { + exit.withError( 'No workers found in this project.' ); + } + } else if ( name ) { + workers = [ findWorker( projectDir, name ) ]; + } else { + exit.withError( '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 || [] ).join( '; ' ) || 'unknown error'; + console.log( `✕ "${ worker.manifest.name }" is invalid: ${ errors }` ); + } else { + const phases = ( result?.phases || [] ).join( ', ' ) || 'none'; + console.log( `✓ "${ worker.manifest.name }" is valid (phases: ${ phases })` ); + } + } + + 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: err.message, + } ); + exit.withError( `Failed to validate edge worker: ${ err.message }` ); + } + + if ( invalidCount > 0 ) { + exit.withError( `${ invalidCount } worker(s) failed validation.` ); + } +} + +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..284089ecb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -112,6 +112,13 @@ export default function API( { } if ( CombinedGraphQLErrors.is( error ) && globalGraphQLErrorHandlingEnabled ) { + // The full error objects carry `path`/`extensions` pinpointing the field + // that failed server-side, plus whatever partial data survived. + debug( 'GraphQL errors in response: %s', JSON.stringify( error.errors, null, 2 ) ); + if ( error.data ) { + debug( 'Partial response data: %s', JSON.stringify( error.data, 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..17850ef4d --- /dev/null +++ b/src/lib/api/edge-workers.ts @@ -0,0 +1,248 @@ +/** + * GraphQL access for edge workers. + * + * The schema exposes workers under `app.environments[].edgeWorkers`, with + * `source`/`wasmBinary` as on-demand fields, 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 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 pickEnvWorkers( result: EdgeWorkersQueryResult | undefined, envId: number ): EdgeWorker[] { + const env = result?.app?.environments?.find( candidate => candidate.id === envId ); + return env?.edgeWorkers ?? []; +} + +/** List the edge workers deployed to an environment (without source/wasm). */ +export async function listEdgeWorkers( appId: number, envId: number ): Promise< EdgeWorker[] > { + const api = API(); + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkers($appId: Int!) { + app(id: $appId) { + environments { + id + edgeWorkers { + ${ EDGE_WORKER_FIELDS } + } + } + } + } + `, + variables: { appId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data, envId ); +} + +/** + * Fetch a single worker by name, including the on-demand `source` and + * `wasmBinary` fields. The schema has no single-worker query, so this requests + * those fields across the environment's workers and filters client-side. + */ +export async function getEdgeWorker( + appId: number, + envId: number, + name: string +): Promise< EdgeWorker | null > { + const api = API(); + const response = await api.query< EdgeWorkersQueryResult >( { + query: gql` + query EdgeWorkerDetail($appId: Int!) { + app(id: $appId) { + environments { + id + edgeWorkers { + ${ EDGE_WORKER_FIELDS } + source + wasmBinary + } + } + } + } + `, + variables: { appId }, + fetchPolicy: 'no-cache', + } ); + + return pickEnvWorkers( response.data, envId ).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 | null > { + const api = API(); + 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 response.data?.createEdgeWorker ?? null; +} + +export async function updateEdgeWorker( + envId: number, + edgeWorkerId: number, + input: EdgeWorkerWriteInput +): Promise< EdgeWorker | null > { + const api = API(); + 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 response.data?.updateEdgeWorker ?? null; +} + +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. Returns null when the + * mutation yields no result. + */ +export async function validateEdgeWorker( + envId: number, + wasmBinary: string +): Promise< EdgeWorkerValidationResult | null > { + const api = API(); + 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 response.data?.validateEdgeWorker ?? null; +} + +export async function setEdgeWorkerActive( + envId: number, + edgeWorkerId: number, + active: boolean +): Promise< EdgeWorker | null > { + const api = API(); + 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 response.data?.setEdgeWorkerActive ?? null; +} + +export async function deleteEdgeWorker( envId: number, edgeWorkerId: number ): Promise< boolean > { + const api = API(); + const response = await api.mutate< { deleteEdgeWorker: boolean | null } >( { + mutation: gql` + mutation DeleteEdgeWorker($input: DeleteEdgeWorkerInput!) { + deleteEdgeWorker(input: $input) + } + `, + variables: { input: { environmentId: envId, edgeWorkerId } }, + } ); + + return response.data?.deleteEdgeWorker ?? false; +} diff --git a/src/lib/edge-workers/index.ts b/src/lib/edge-workers/index.ts new file mode 100644 index 000000000..a6d111cfd --- /dev/null +++ b/src/lib/edge-workers/index.ts @@ -0,0 +1,66 @@ +/** + * 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 path from 'node:path'; + +import UserError from '../user-error'; +import { readProjectDescriptor } from './project'; +import { getToolchain } from './toolchains'; + +import type { DiscoveredWorker } from './types'; + +export * from './types'; +export * from './project'; +export * from './location'; +export { getToolchain } from './toolchains'; + +/** Conventional output directory for compiled artifacts, relative to the project root. */ +export const BUILD_DIR = 'build'; + +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 wasmPath = path.join( projectDir, BUILD_DIR, `${ worker.manifest.name }.wasm` ); + if ( ! fs.existsSync( wasmPath ) ) { + throw new UserError( + `No compiled artifact found for "${ worker.manifest.name }" at "${ wasmPath }". ` + + 'Run `vip edge-workers build` first, or deploy without `--skip-build`.' + ); + } + + 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 | undefined { + const entry = path.resolve( worker.dir, worker.manifest.entry ); + try { + return fs.readFileSync( entry, 'utf8' ); + } catch { + return undefined; + } +} diff --git a/src/lib/edge-workers/location.ts b/src/lib/edge-workers/location.ts new file mode 100644 index 000000000..7c5de7e57 --- /dev/null +++ b/src/lib/edge-workers/location.ts @@ -0,0 +1,26 @@ +/** + * 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 { EDGE_WORKER_LOCATION_OPERATORS } from './types'; + +import type { EdgeWorkerLocation, EdgeWorkerLocationOperator } from './types'; + +export function parseLocationOption( raw: string ): EdgeWorkerLocation { + // 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 ) { + 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/project.ts b/src/lib/edge-workers/project.ts new file mode 100644 index 000000000..cd07315d4 --- /dev/null +++ b/src/lib/edge-workers/project.ts @@ -0,0 +1,185 @@ +/** + * 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 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 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 ) ); +} + +/** + * 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 ) { + 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 ); + let raw: string; + try { + raw = fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read project descriptor at "${ file }".` ); + } + + let parsed: ProjectDescriptor; + try { + parsed = JSON.parse( raw ) as ProjectDescriptor; + } catch { + throw new UserError( `Project descriptor at "${ file }" is not valid JSON.` ); + } + + if ( ! parsed.type ) { + throw new UserError( `Project descriptor at "${ file }" is missing a "type" field.` ); + } + + return parsed; +} + +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 ); + let raw: string; + try { + raw = fs.readFileSync( file, 'utf8' ); + } catch { + throw new UserError( `Could not read worker manifest at "${ file }".` ); + } + + let parsed: WorkerManifest; + try { + parsed = JSON.parse( raw ) as WorkerManifest; + } catch { + throw new UserError( `Worker manifest at "${ file }" is not valid JSON.` ); + } + + if ( ! parsed.name ) { + throw new UserError( `Worker manifest at "${ file }" is missing a "name" field.` ); + } + + return parsed; +} + +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[] = []; + 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; + } + + workers.push( { dir, manifest: readWorkerManifest( dir ) } ); + } + + 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 || 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..c8de96077 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/constants.ts @@ -0,0 +1,11 @@ +/** + * 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.0'; +export const ASSEMBLYSCRIPT_VERSION = '^0.27.0'; +export const DEFAULT_ENTRY = 'assembly/index.ts'; +export const BUILD_DIR = 'build'; 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..3f251b708 --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/index.ts @@ -0,0 +1,146 @@ +/** + * 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 { BUILD_DIR, DEFAULT_ENTRY, SDK_PACKAGE, SDK_VERSION } from './constants'; +import { GITIGNORE, PACKAGE_JSON, README, starterWorker, TSCONFIG_JSON } from './templates'; +import UserError from '../../../user-error'; +import { + PROJECT_DESCRIPTOR_FILE, + WORKERS_DIR, + writeProjectDescriptor, + writeWorkerManifest, +} from '../../project'; + +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 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 { + if ( fs.existsSync( path.join( projectDir, PROJECT_DESCRIPTOR_FILE ) ) ) { + throw new UserError( + `An edge-workers project already exists at "${ projectDir }" (found ${ PROJECT_DESCRIPTOR_FILE }).` + ); + } + + // 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 { + 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 entry = path.resolve( worker.dir, worker.manifest.entry || DEFAULT_ENTRY ); + if ( ! fs.existsSync( entry ) ) { + throw new UserError( `Worker entry file not found: "${ entry }".` ); + } + + const nodeModules = path.join( projectDir, 'node_modules' ); + const outFile = path.join( projectDir, BUILD_DIR, `${ worker.manifest.name }.wasm` ); + fs.mkdirSync( path.dirname( outFile ), { recursive: true } ); + + 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 outFile; + }, +}; + +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..51ac29d3e --- /dev/null +++ b/src/lib/edge-workers/toolchains/assemblyscript/templates.ts @@ -0,0 +1,100 @@ +/** + * 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, + BUILD_DIR, + DEFAULT_ENTRY, + SDK_PACKAGE, + SDK_VERSION, +} from './constants'; +import { 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 +\`\`\` + +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 { + Request, + Response, + onClientRequest, + onOriginRequest, + onClientResponse, + onOriginResponse, +} from '${ SDK_PACKAGE }'; + +// A worker re-exports \`alloc\` plus the host entrypoints for each phase it +// handles. Drop the ones you don't use (and their hooks below). +export { + alloc, + on_client_request, + on_origin_request, + on_client_response, + on_origin_response, +} from '${ SDK_PACKAGE }/assembly/index'; + +// Client request: runs before the cache lookup, on every request. +onClientRequest( ( req: Request ): void => {} ); + +// Origin request: runs on a cache miss, before forwarding to origin. +onOriginRequest( ( req: Request ): void => {} ); + +// Client response: runs before the response reaches the client. +onClientResponse( ( res: Response ): void => {} ); + +// Origin response: runs after origin responds (cache miss); what you set here +// governs what the host caches. +onOriginResponse( ( res: 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..c839ffd0f --- /dev/null +++ b/src/lib/edge-workers/types.ts @@ -0,0 +1,92 @@ +/** + * 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; + 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; + /** Only present when explicitly requested (on-demand field). */ + wasmBinary?: string | null; +}