Skip to content

Commit

Permalink
fix: update build and plugin packages to support JS code generation (#…
Browse files Browse the repository at this point in the history
…487)

Fixes #479
  • Loading branch information
chrispcampbell authored May 24, 2024
1 parent 42d4dc6 commit 18b0873
Show file tree
Hide file tree
Showing 40 changed files with 439 additions and 227 deletions.
1 change: 0 additions & 1 deletion examples/hello-world/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"@sdeverywhere/build": "^0.3.4",
"@sdeverywhere/cli": "^0.7.23",
"@sdeverywhere/plugin-check": "^0.3.5",
"@sdeverywhere/plugin-wasm": "^0.2.3",
"@sdeverywhere/plugin-worker": "^0.2.3",
"vite": "^4.4.9"
}
Expand Down
6 changes: 1 addition & 5 deletions examples/hello-world/sde.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { checkPlugin } from '@sdeverywhere/plugin-check'
import { wasmPlugin } from '@sdeverywhere/plugin-wasm'
import { workerPlugin } from '@sdeverywhere/plugin-worker'

export async function config() {
Expand All @@ -15,10 +14,7 @@ export async function config() {
},

plugins: [
// Generate a `generated-model.js` file containing the Wasm model
wasmPlugin(),

// Generate a `worker.js` file that runs the Wasm model in a worker
// Generate a `worker.js` file that runs the generated model in a worker
workerPlugin(),

// Run model check
Expand Down
2 changes: 1 addition & 1 deletion examples/sir/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sir

This example directory contains the class SIR (Susceptible-Infectious-Recovered) model of
This example directory contains the classic SIR (Susceptible-Infectious-Recovered) model of
infectious disease.
It is intended to demonstrate the use of the `@sdeverywhere/create` package to quickly
set up a new project that uses the provided config files to generate a simple web application.
Expand Down
27 changes: 24 additions & 3 deletions examples/template-default/sde.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,27 @@ const packagePath = (...parts) => joinPath(__dirname, 'packages', ...parts)
const appPath = (...parts) => packagePath('app', ...parts)
const corePath = (...parts) => packagePath('core', ...parts)

//
// NOTE: This template can generate a model as a WebAssembly module (runs faster,
// but requires additional tools to be installed) or in pure JavaScript format (runs
// slower, but is simpler to build). Regardless of which approach you choose, the
// same APIs (e.g., `ModelRunner`) can be used to run the generated model.
//
// If `genFormat` is 'c', the sde compiler will generate C code, but `plugin-wasm`
// is needed to convert the C code to a WebAssembly module, in which case
// the Emscripten SDK must be installed (the `@sdeverywhere/create` package can
// help with this; see "Quick Start" instructions).
//
// If `genFormat` is 'js', the sde compiler will generate JavaScript code that runs
// in the browser or in Node.js without the additional Emscripten build step.
//
const genFormat = 'js'

export async function config() {
return {
// Specify the format of the generated code, either 'js' or 'c'
genFormat,

// Specify the Vensim model to read
modelFiles: ['MODEL_NAME.mdl'],

Expand All @@ -34,10 +53,12 @@ export async function config() {
}),

plugins: [
// Generate a `generated-model.js` file containing the Wasm model
wasmPlugin(),
// If targeting WebAssembly, generate a `generated-model.js` file
// containing the Wasm model
genFormat === 'c' && wasmPlugin(),

// Generate a `worker.js` file that runs the Wasm model in a worker
// Generate a `worker.js` file that runs the model asynchronously on a
// worker thread for improved responsiveness
workerPlugin({
outputPaths: [corePath('src', 'model', 'generated', 'worker.js')]
}),
Expand Down
28 changes: 25 additions & 3 deletions examples/template-minimal/sde.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@ import { checkPlugin } from '@sdeverywhere/plugin-check'
import { wasmPlugin } from '@sdeverywhere/plugin-wasm'
import { workerPlugin } from '@sdeverywhere/plugin-worker'

//
// NOTE: This template can generate a model as a WebAssembly module (runs faster,
// but requires additional tools to be installed) or in pure JavaScript format (runs
// slower, but is simpler to build). Regardless of which approach you choose, the
// same APIs (e.g., `ModelRunner`) can be used to run the generated model.
//
// If `genFormat` is 'c', the sde compiler will generate C code, but `plugin-wasm`
// is needed to convert the C code to a WebAssembly module, in which case
// the Emscripten SDK must be installed (the `@sdeverywhere/create` package can
// help with this; see "Quick Start" instructions).
//
// If `genFormat` is 'js', the sde compiler will generate JavaScript code that runs
// in the browser or in Node.js without the additional Emscripten build step.
//
const genFormat = 'js'

export async function config() {
return {
// Specify the format of the generated code, either 'js' or 'c'
genFormat,

// Specify the Vensim model to read
modelFiles: ['MODEL_NAME.mdl'],

modelSpec: async () => {
Expand All @@ -24,10 +44,12 @@ export async function config() {
},

plugins: [
// Generate a `generated-model.js` file containing the Wasm model
wasmPlugin(),
// If targeting WebAssembly, generate a `generated-model.js` file
// containing the Wasm model
genFormat === 'c' && wasmPlugin(),

// Generate a `worker.js` file that runs the Wasm model in a worker
// Generate a `worker.js` file that runs the model asynchronously on a
// worker thread for improved responsiveness
workerPlugin(),

// Run model check
Expand Down
22 changes: 12 additions & 10 deletions packages/build/docs/interfaces/Plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ listed below:
- preGenerate
- preProcessMdl
- postProcessMdl
- preGenerateC
- postGenerateC
- preGenerateCode
- postGenerateCode
- postGenerate
- postBuild
- watch (only called once after initial build steps when mode==development)
Expand Down Expand Up @@ -100,42 +100,44 @@ The modified mdl file content (if postprocessing was needed).

___

### preGenerateC
### preGenerateCode

`Optional` **preGenerateC**(`context`): `Promise`<`void`\>
`Optional` **preGenerateCode**(`context`, `format`): `Promise`<`void`\>

Called before SDE generates a C file from the mdl file.
Called before SDE generates a JS or C file from the mdl file.

#### Parameters

| Name | Type | Description |
| :------ | :------ | :------ |
| `context` | [`BuildContext`](../classes/BuildContext.md) | The build context (for logging, etc). |
| `format` | ``"js"`` \| ``"c"`` | The generated code format, either 'js' or 'c'. |

#### Returns

`Promise`<`void`\>

___

### postGenerateC
### postGenerateCode

`Optional` **postGenerateC**(`context`, `cContent`): `Promise`<`string`\>
`Optional` **postGenerateCode**(`context`, `format`, `content`): `Promise`<`string`\>

Called after SDE generates a C file from the mdl file.
Called after SDE generates a JS or C file from the mdl file.

#### Parameters

| Name | Type | Description |
| :------ | :------ | :------ |
| `context` | [`BuildContext`](../classes/BuildContext.md) | The build context (for logging, etc). |
| `cContent` | `string` | The resulting C file content. |
| `format` | ``"js"`` \| ``"c"`` | The generated code format, either 'js' or 'c'. |
| `content` | `string` | The resulting JS or C file content. |

#### Returns

`Promise`<`string`\>

The modified C file content (if postprocessing was needed).
The modified JS or C file content (if postprocessing was needed).

___

Expand Down
10 changes: 10 additions & 0 deletions packages/build/docs/interfaces/ResolvedConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ ___

Paths to files that when changed will trigger a rebuild in watch mode. These
can be paths to files or glob patterns (relative to the project directory).

___

### genFormat

**genFormat**: ``"js"`` \| ``"c"``

The code format to generate. If 'js', the model will be compiled to a JavaScript
file. If 'c', the model will be compiled to a C file (in which case an additional
plugin will be needed to convert the C code to a WebAssembly module).
11 changes: 11 additions & 0 deletions packages/build/docs/interfaces/UserConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ If left undefined, this will resolve to the `modelFiles` array.

___

### genFormat

`Optional` **genFormat**: ``"js"`` \| ``"c"``

The code format to generate. If 'js', the model will be compiled to a JavaScript
file. If 'c', the model will be compiled to a C file (in which case an additional
plugin will be needed to convert the C code to a WebAssembly module). If undefined,
defaults to 'js'.

___

### plugins

`Optional` **plugins**: [`Plugin`](Plugin.md)[]
Expand Down
7 changes: 7 additions & 0 deletions packages/build/src/_shared/resolved-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface ResolvedConfig {
*/
watchPaths: string[]

/**
* The code format to generate. If 'js', the model will be compiled to a JavaScript
* file. If 'c', the model will be compiled to a C file (in which case an additional
* plugin will be needed to convert the C code to a WebAssembly module).
*/
genFormat: 'js' | 'c'

/**
* The path to the `@sdeverywhere/cli` package. This is currently only used to get
* access to the files in the `src/c` directory.
Expand Down
72 changes: 46 additions & 26 deletions packages/build/src/build/impl/gen-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,35 @@ export async function generateModel(context: BuildContext, plugins: Plugin[]): P
}
}

// Generate the C file
// Generate the JS or C file
for (const plugin of plugins) {
if (plugin.preGenerateC) {
await plugin.preGenerateC(context)
if (plugin.preGenerateCode) {
await plugin.preGenerateCode(context, config.genFormat)
}
}
await generateC(context, config.sdeDir, sdeCmdPath, prepDir)
await generateCode(context, config.sdeDir, sdeCmdPath, prepDir)
const generatedCodeFile = `processed.${config.genFormat}`
const generatedCodePath = joinPath(prepDir, 'build', generatedCodeFile)
for (const plugin of plugins) {
if (plugin.postGenerateC) {
const cPath = joinPath(prepDir, 'build', 'processed.c')
let cContent = await readFile(cPath, 'utf8')
cContent = await plugin.postGenerateC(context, cContent)
await writeFile(cPath, cContent)
if (plugin.postGenerateCode) {
let generatedCodeContent = await readFile(generatedCodePath, 'utf8')
generatedCodeContent = await plugin.postGenerateCode(context, config.genFormat, generatedCodeContent)
await writeFile(generatedCodePath, generatedCodeContent)
}
}

if (config.genFormat === 'js') {
// When generating JS code, copy the generated JS file to the `staged/model`
// directory, because that's where plugin-worker expects to find it, but also
// set it up to be copied to the `prepDir`, which is where other code expects
// to find it
// TODO: Maybe we can change plugin-worker to use the one in `prepDir`, and/or
// add a build config setting to allow for customizing the output location
const outputJsFile = 'generated-model.js'
const stagedOutputJsPath = context.prepareStagedFile('model', outputJsFile, prepDir, outputJsFile)
await copyFile(generatedCodePath, stagedOutputJsPath)
}

const t1 = performance.now()
const elapsed = ((t1 - t0) / 1000).toFixed(1)
log('info', `Done generating model (${elapsed}s)`)
Expand Down Expand Up @@ -165,36 +178,43 @@ async function flattenMdls(
}

/**
* Generate a C file from the `processed.mdl` file.
* Generate a JS or C file from the `processed.mdl` file.
*/
async function generateC(context: BuildContext, sdeDir: string, sdeCmdPath: string, prepDir: string): Promise<void> {
log('verbose', ' Generating C code')
async function generateCode(context: BuildContext, sdeDir: string, sdeCmdPath: string, prepDir: string): Promise<void> {
const genFormat = context.config.genFormat
const genFormatName = genFormat.toUpperCase()
log('verbose', ` Generating ${genFormatName} code`)

// Use SDE to generate both a C version of the model (`--genc`) AND a JSON list of all model
// Use SDE to generate both a JS/C version of the model (`--outformat`) AND a JSON list of all model
// dimensions and variables (`--list`)
const command = sdeCmdPath
const gencArgs = ['generate', '--genc', '--list', '--spec', 'spec.json', 'processed']
const gencOutput = await context.spawnChild(prepDir, command, gencArgs, {
const outFormat = `--outformat=${genFormat}`
const genCmdArgs = ['generate', outFormat, '--list', '--spec', 'spec.json', 'processed']
const genCmdOutput = await context.spawnChild(prepDir, command, genCmdArgs, {
// By default, ignore lines that start with "WARNING: Data for" since these are often harmless
// TODO: Don't filter by default, but make it configurable
// ignoredMessageFilter: 'WARNING: Data for'
// The default error message from `spawnChild` is not very informative, so the
// following allows us to throw our own error
ignoreError: true
})
if (gencOutput.exitCode !== 0) {
throw new Error(`Failed to generate C code: 'sde generate' command failed (code=${gencOutput.exitCode})`)
if (genCmdOutput.exitCode !== 0) {
throw new Error(
`Failed to generate ${genFormatName} code: 'sde generate' command failed (code=${genCmdOutput.exitCode})`
)
}

// Copy SDE's supporting C files into the build directory
const buildDir = joinPath(prepDir, 'build')
const sdeCDir = joinPath(sdeDir, 'src', 'c')
const files = await readdir(sdeCDir)
const copyOps = []
for (const file of files) {
if (file.endsWith('.c') || file.endsWith('.h')) {
copyOps.push(copyFile(joinPath(sdeCDir, file), joinPath(buildDir, file)))
if (genFormat === 'c') {
// Copy SDE's supporting C files into the build directory
const buildDir = joinPath(prepDir, 'build')
const sdeCDir = joinPath(sdeDir, 'src', 'c')
const files = await readdir(sdeCDir)
const copyOps = []
for (const file of files) {
if (file.endsWith('.c') || file.endsWith('.h')) {
copyOps.push(copyFile(joinPath(sdeCDir, file), joinPath(buildDir, file)))
}
}
await Promise.all(copyOps)
}
await Promise.all(copyOps)
}
15 changes: 15 additions & 0 deletions packages/build/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,28 @@ function resolveUserConfig(
watchPaths = modelFiles
}

// Validate the code generation format
const rawGenFormat = userConfig.genFormat || 'js'
let genFormat: 'js' | 'c'
switch (rawGenFormat) {
case 'js':
genFormat = 'js'
break
case 'c':
genFormat = 'c'
break
default:
throw new Error(`The configured genFormat value is invalid; must be either 'js' or 'c'`)
}

return {
mode,
rootDir,
prepDir,
modelFiles,
modelInputPaths,
watchPaths,
genFormat,
sdeDir,
sdeCmdPath
}
Expand Down
8 changes: 8 additions & 0 deletions packages/build/src/config/user-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export interface UserConfig {
*/
watchPaths?: string[]

/**
* The code format to generate. If 'js', the model will be compiled to a JavaScript
* file. If 'c', the model will be compiled to a C file (in which case an additional
* plugin will be needed to convert the C code to a WebAssembly module). If undefined,
* defaults to 'js'.
*/
genFormat?: 'js' | 'c'

/**
* The array of plugins that are used to customize the build process. These will be
* executed in the order defined here.
Expand Down
Loading

0 comments on commit 18b0873

Please sign in to comment.