diff --git a/.github/scripts/check-version-sync.sh b/.github/scripts/check-version-sync.sh
index 4354026..0adac00 100755
--- a/.github/scripts/check-version-sync.sh
+++ b/.github/scripts/check-version-sync.sh
@@ -75,12 +75,12 @@ runtime_constant="$(extract_quoted_version runtime-js/bootstrap/index.ts \
'.*runtimeJsVersion = "([0-9]+\.[0-9]+\.[0-9]+)";.*')"
web_api_constant="$(extract_quoted_version runtime-js/web-api/index.ts \
'.*webApiVersion = "([0-9]+\.[0-9]+\.[0-9]+)";.*')"
-website_version="$(extract_quoted_version 'website/app/(home)/page.tsx' \
+website_version="$(extract_quoted_version 'website/components/home/home-page.tsx' \
'.*softwareVersion: "([0-9]+\.[0-9]+\.[0-9]+)",.*')"
for version_entry in \
"runtime-js/bootstrap/index.ts:${runtime_constant}" \
"runtime-js/web-api/index.ts:${web_api_constant}" \
- "website/app/(home)/page.tsx:${website_version}"; do
+ "website/components/home/home-page.tsx:${website_version}"; do
version_file="${version_entry%%:*}"
declared_version="${version_entry#*:}"
if [[ "$declared_version" != "$version" ]]; then
diff --git a/.github/scripts/release-notes.py b/.github/scripts/release-notes.py
new file mode 100644
index 0000000..772769d
--- /dev/null
+++ b/.github/scripts/release-notes.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Extract one version's release body from the repository changelog."""
+import argparse
+from datetime import date
+import json
+from pathlib import Path
+import re
+import sys
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def version_headings(text):
+ headings = []
+ fence = None
+ offset = 0
+ for line in text.splitlines(keepends=True):
+ marker = re.fullmatch(r" {0,3}(`{3,}|~{3,})([^\n]*)\n?", line)
+ if fence:
+ if (marker and marker[1][0] == fence[0]
+ and len(marker[1]) >= len(fence) and not marker[2].strip()):
+ fence = None
+ elif marker and (marker[1][0] == "~" or "`" not in marker[2]):
+ fence = marker[1]
+ else:
+ heading = re.fullmatch(r"## (.+?)\s*", line)
+ if heading:
+ headings.append((heading[1], offset, offset + len(line)))
+ offset += len(line)
+ if fence:
+ raise ValueError("unclosed changelog code fence")
+ return headings
+
+
+def extract(text, version, require_date=False):
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version):
+ raise ValueError("expected an unprefixed release version")
+ text = text.replace("\r\n", "\n")
+ headings = version_headings(text)
+ matches = [i for i, h in enumerate(headings)
+ if re.fullmatch(r"\[" + re.escape(version) + r"\](?: - .+)?", h[0])]
+ if len(matches) != 1:
+ raise ValueError(f"expected exactly one changelog section for {version}")
+ i = matches[0]
+ if require_date:
+ dated = re.fullmatch(r"\[" + re.escape(version) + r"\] - (\d{4}-\d{2}-\d{2})", headings[i][0])
+ if not dated:
+ raise ValueError(f"release {version} requires a YYYY-MM-DD date, not a draft status")
+ try:
+ date.fromisoformat(dated[1])
+ except ValueError:
+ raise ValueError(f"invalid release date for {version}: {dated[1]}") from None
+ end = headings[i + 1][1] if i + 1 < len(headings) else len(text)
+ body = text[headings[i][2]:end].strip()
+ if not any(line.strip() and not line.lstrip().startswith('#') for line in body.splitlines()):
+ raise ValueError(f"empty changelog section for {version}")
+ return body + "\n"
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("version", nargs="?")
+ parser.add_argument("--changelog", type=Path, default=ROOT / "CHANGELOG.md")
+ parser.add_argument("--release-tag", help="require the matching vVERSION tag and a valid release date")
+ args = parser.parse_args()
+ version = args.version or json.loads((ROOT / "packages/tysel/package.json").read_text())["version"]
+ try:
+ if args.release_tag is not None and args.release_tag != f"v{version}":
+ raise ValueError(f"release tag {args.release_tag} does not match version {version}")
+ notes = extract(args.changelog.read_text(encoding="utf-8"), version,
+ require_date=args.release_tag is not None)
+ except (OSError, ValueError) as error:
+ parser.exit(1, f"release notes: {error}\n")
+ sys.stdout.write(notes)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/test-release-notes.py b/.github/scripts/test-release-notes.py
new file mode 100644
index 0000000..9eb39b8
--- /dev/null
+++ b/.github/scripts/test-release-notes.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+import importlib.util
+from pathlib import Path
+import unittest
+import subprocess
+import sys
+import tempfile
+
+spec = importlib.util.spec_from_file_location("release_notes", Path(__file__).with_name("release-notes.py"))
+notes = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(notes)
+
+
+class ReleaseNotesTests(unittest.TestCase):
+ def test_exact_version_and_boundaries(self):
+ text = "# Changelog\n\n## [Unreleased]\nFuture\n\n## [0.3.0] - Unreleased\n\n### Fixed\n- Recovery.\n\n## [0.2.0]\nOld\n"
+ self.assertEqual(notes.extract(text, "0.3.0"), "### Fixed\n- Recovery.\n")
+ self.assertEqual(notes.extract(text, "0.2.0"), "Old\n")
+
+ def test_prerelease_is_not_stable(self):
+ text = "## [0.3.0-rc.1]\nPreview\n## [0.3.0]\nStable\n"
+ self.assertEqual(notes.extract(text, "0.3.0-rc.1"), "Preview\n")
+ self.assertEqual(notes.extract(text, "0.3.0"), "Stable\n")
+
+ def test_fenced_headings_preserve_migration_steps(self):
+ for opening, interior, closing in [
+ ("```markdown", "## Example heading", "```"),
+ ("~~~~markdown", "## [0.3.0]\n~~~\n## More examples", "~~~~"),
+ (" ````", "```\n## [0.2.0]", " `````"),
+ ]:
+ body = f"### Migration\n{opening}\n{interior}\n{closing}\n- Required migration step.\n"
+ text = f"## [0.3.0] - 2026-09-09\n{body}\n## [0.2.0]\nOld\n"
+ with self.subTest(opening=opening):
+ self.assertEqual(notes.extract(text, "0.3.0", require_date=True), body)
+ self.assertEqual(notes.extract(text.replace("\n", "\r\n"), "0.3.0"), body)
+ with self.assertRaisesRegex(ValueError, "unclosed"):
+ notes.extract("## [0.3.0]\n```\n## Example\n", "0.3.0")
+
+ def test_release_dates(self):
+ for suffix in ["", " - Unreleased", " - Unpublished draft", " - 2026-02-29", " - 2026-13-01", " - 2026-9-09"]:
+ text = f"## [0.3.0]{suffix}\n- Changes.\n"
+ with self.subTest(suffix=suffix):
+ self.assertEqual(notes.extract(text, "0.3.0"), "- Changes.\n")
+ with self.assertRaises(ValueError):
+ notes.extract(text, "0.3.0", require_date=True)
+ for version in ["0.3.0", "0.3.0-rc.1"]:
+ self.assertEqual(notes.extract(f"## [{version}] - 2024-02-29\n- Changes.\n", version, require_date=True), "- Changes.\n")
+
+ def test_cli_release_mode(self):
+ script = str(Path(__file__).with_name("release-notes.py").resolve())
+ with tempfile.TemporaryDirectory() as folder:
+ changelog = Path(folder) / "CHANGELOG.md"
+ for status, tag, succeeds in [
+ ("Unreleased", None, True),
+ ("Unreleased", "v0.3.0", False),
+ ("2026-09-09", "v0.3.0", True),
+ ("2026-09-09", "v0.2.0", False),
+ ("2026-02-30", "v0.3.0", False),
+ ]:
+ changelog.write_text(f"## [0.3.0] - {status}\n- Changes.\n")
+ command = [sys.executable, script, "0.3.0", "--changelog", str(changelog)]
+ if tag:
+ command += ["--release-tag", tag]
+ result = subprocess.run(command, capture_output=True, text=True)
+ with self.subTest(status=status, tag=tag):
+ self.assertEqual(result.returncode == 0, succeeds, result.stderr)
+ self.assertEqual(result.stdout, "- Changes.\n" if succeeds else "")
+
+ def test_missing_duplicate_empty_and_invalid_fail(self):
+ for text, version in [
+ ("## [0.2.0]\nOld", "0.3.0"),
+ ("## [0.3.0]\nA\n## [0.3.0] - Today\nB", "0.3.0"),
+ ("## [0.3.0]\n\n### Fixed\n", "0.3.0"),
+ ("## [0.3.0]\nValid", "v0.3.0"),
+ ]:
+ with self.subTest(text=text, version=version), self.assertRaises(ValueError):
+ notes.extract(text, version)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3cff632..fae8380 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
+ - name: Validate release notes
+ run: |
+ python3 .github/scripts/test-release-notes.py
+ python3 .github/scripts/release-notes.py > /dev/null
- name: Classify changed files
id: classify
shell: bash
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3d8f2e3..7a6ef6a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,7 +22,22 @@ env:
TYSEL_TOOLCHAIN_IMAGE: ghcr.io/wangcch/tysel-toolchain
jobs:
+ release-notes:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Validate changelog before building release artifacts
+ run: |
+ python3 .github/scripts/test-release-notes.py
+ args=()
+ if [[ "$GITHUB_REF_TYPE" == tag ]]; then
+ args+=(--release-tag "$GITHUB_REF_NAME")
+ fi
+ python3 .github/scripts/release-notes.py "${args[@]}" > /dev/null
+
policy:
+ needs: release-notes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -100,6 +115,7 @@ jobs:
if-no-files-found: error
package-npm:
+ needs: release-notes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -124,6 +140,7 @@ jobs:
if-no-files-found: error
package-rust-sdk:
+ needs: release-notes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -139,6 +156,7 @@ jobs:
if-no-files-found: error
package-component-starters:
+ needs: release-notes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -186,6 +204,7 @@ jobs:
if-no-files-found: error
build-linux:
+ needs: release-notes
strategy:
fail-fast: false
matrix:
@@ -260,6 +279,7 @@ jobs:
if-no-files-found: error
build-darwin:
+ needs: release-notes
strategy:
fail-fast: false
matrix:
@@ -543,13 +563,15 @@ jobs:
version="$(bash .github/scripts/check-version-sync.sh)"
channel="$(bash .github/scripts/release-channel.sh "$version")"
bash .github/scripts/verify-release-assets.sh target/publish "$version"
+ python3 .github/scripts/release-notes.py "$version" --release-tag "$GITHUB_REF_NAME" > target/release-notes.md
if gh release view "$GITHUB_REF_NAME" --json isDraft > target/release-state.json 2>/dev/null; then
jq -e '.isDraft == true' target/release-state.json > /dev/null || {
echo "release is already public and immutable" >&2
exit 1
}
+ gh release edit "$GITHUB_REF_NAME" --notes-file target/release-notes.md
else
- release_flags=(--draft --verify-tag --generate-notes)
+ release_flags=(--draft --verify-tag --notes-file target/release-notes.md)
[[ "$channel" == stable ]] || release_flags+=(--prerelease)
gh release create "$GITHUB_REF_NAME" "${release_flags[@]}"
fi
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index c6d59ea..ee28a54 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -105,9 +105,18 @@ jobs:
- name: Check benchmark evidence loader
run: pnpm test:benchmarks
working-directory: website
+ - name: Check localization contracts
+ run: pnpm test:i18n && pnpm i18n:check
+ working-directory: website
- name: Build static website
run: pnpm build
working-directory: website
+ - name: Verify localized HTML and section links
+ run: python3 scripts/check-localized-html.py
+ working-directory: website
+ - name: Verify sitemap and multilingual SEO
+ run: python3 scripts/check-seo.py
+ working-directory: website
- name: Verify generated documentation is committed
run: |
changes="$(git status --short --untracked-files=all -- website/content)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f9bbef5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,84 @@
+# Changelog
+
+User-visible changes are recorded here. Release notes reuse the matching version
+section; detailed contracts and procedures remain in the linked documentation.
+Use an exact version heading (including any prerelease suffix), and replace
+`Unreleased` with the release date before tagging. Keep entries focused on user
+impact rather than individual commits. PR checks allow `Unreleased`; tag releases
+require a matching version and a valid date before artifact builds start.
+
+## [0.3.0] - 2026-09-09
+
+### Upgrade notes
+
+- Durable stores migrate from log v1/v2 to v3 on open. Stop old writers and back
+ up the complete store first; rollback requires the pre-upgrade snapshot and
+ matching runtime. Legacy tasks without wakeup or execution records are not
+ automatically recovered. See the [upgrade procedure](https://tysel.dev/docs/operations/production#durable-log-v3-upgrade).
+- External writes still need provider idempotency or reconciliation. Applications
+ own outbox delivery: admission retries require the original bundle, and completed
+ tasks must be retained beyond the retry horizon because pruning removes deduplication
+ records. See the [durable contract](https://tysel.dev/reference/runtime/durable).
+
+### Added and fixed
+
+- Recover active durable tasks after process death and preserve recovery across
+ hot reload, with execution fencing and retries for temporary completion-storage failures.
+- Add stable idempotency keys for durable admission and signals, plus retained
+ results and cleanup through `tysel durable result` and `tysel durable prune`.
+- Add Streams, `TextEncoder.encodeInto`, `TextEncoderStream`, body `bytes()` helpers,
+ `Response.error()` and `Response.redirect()`; fix HTTP body byte preservation,
+ URL path handling and malformed query decoding.
+- Add guided project setup, automatic manifest-type synchronization during
+ development, and structured diagnostics for manifest and import errors.
+- Include queue time and body consumption in HTTP deadlines, and drain connections
+ during shutdown.
+- Add reviewed Chinese documentation and localized website navigation.
+- Fix promise retention during stream backpressure.
+
+### Performance
+
+- Cache durable export metadata per source revision and wake the local scheduler
+ after signal commits, while retaining periodic polling for recovery and other instances.
+
+## [0.2.0] - 2026-09-04
+
+- Added verified cross-target packaging for Linux and macOS on x64 and arm64,
+ with authenticated runtime downloads and offline reuse of verified cached
+ runtimes. Cross-target builds require a managed installation and trust policy.
+- Published Linux toolchain images and hardened `tysel image` handling of
+ existing binaries, release sidecars and generated container metadata.
+- Added `tysel test --filter` and `--list`, including machine-readable discovery
+ and exact test-ID selection.
+- Added source-mapped development diagnostic events and improved installer
+ feedback and the development server's displayed URL.
+- Upgraded QuickJS-NG to 0.16.2.
+
+[Release](https://github.com/wangcch/tysel/releases/tag/v0.2.0) ·
+[Changes since 0.1.1](https://github.com/wangcch/tysel/compare/v0.1.1...v0.2.0)
+
+## [0.1.1] - 2026-09-02
+
+- Corrected TypeScript SDK publication to the scoped `@tysel/sdk` package.
+- Made registry publication recoverable after partial release failures.
+
+[Release](https://github.com/wangcch/tysel/releases/tag/v0.1.1) ·
+[Changes since 0.1.0](https://github.com/wangcch/tysel/compare/v0.1.0...v0.1.1)
+
+## [0.1.0] - Unpublished draft
+
+The tag exists, but its GitHub Release remains a draft with no publication date.
+This section records the tagged baseline, not a completed public release.
+
+- Introduced native packaging of TypeScript applications as a single executable,
+ with host-platform builds and a Web-API-first runtime.
+- Provided HTTP services, cron and queue workers, MCP tools and explicit host
+ capability configuration.
+- Added durable steps, effects, sleeps, retries and signal waits with persisted
+ boundary history. Active-execution crash recovery was not guaranteed; the
+ recovery and admission fixes are recorded under 0.3.0.
+- Included experimental Rust and Go Wasm Component tasks with bounded execution.
+- Provided project setup, development, testing, compatibility checks and signed
+ release verification tooling.
+
+[Tagged source](https://github.com/wangcch/tysel/tree/v0.1.0)
diff --git a/Cargo.lock b/Cargo.lock
index de6b945..651f3b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -434,6 +434,18 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -703,6 +715,16 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+[[package]]
+name = "dialoguer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
+dependencies = [
+ "console",
+ "shell-words",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -783,6 +805,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -805,7 +833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2931,7 +2959,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3028,7 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3139,6 +3167,12 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3331,7 +3365,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3639,7 +3673,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "tysel-bench-compare"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"clap",
@@ -3655,7 +3689,7 @@ dependencies = [
[[package]]
name = "tysel-build"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"ed25519-dalek",
@@ -3674,14 +3708,14 @@ dependencies = [
[[package]]
name = "tysel-cap-fs"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"libc",
]
[[package]]
name = "tysel-cap-llm"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"bytes",
"http-body-util",
@@ -3697,7 +3731,7 @@ dependencies = [
[[package]]
name = "tysel-cap-mcp"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde",
"serde_json",
@@ -3706,7 +3740,7 @@ dependencies = [
[[package]]
name = "tysel-cap-postgres"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"bytes",
"futures-util",
@@ -3720,7 +3754,7 @@ dependencies = [
[[package]]
name = "tysel-cap-redis"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"redis",
"tokio",
@@ -3729,7 +3763,7 @@ dependencies = [
[[package]]
name = "tysel-cap-sqlite"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"rusqlite",
"serde_json",
@@ -3738,7 +3772,7 @@ dependencies = [
[[package]]
name = "tysel-capability"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"semver",
"serde",
@@ -3747,10 +3781,12 @@ dependencies = [
[[package]]
name = "tysel-cli"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"clap",
+ "dialoguer",
+ "json-strip-comments",
"libc",
"notify",
"reqwest",
@@ -3763,12 +3799,14 @@ dependencies = [
"tysel-cap-mcp",
"tysel-capability",
"tysel-distribution",
+ "tysel-durable",
"tysel-engine",
"tysel-engine-qjs",
"tysel-manifest",
"tysel-observability",
"tysel-package",
"tysel-runtime",
+ "tysel-task",
"tysel-task-rpc",
"tysel-testkit",
"wat",
@@ -3776,7 +3814,7 @@ dependencies = [
[[package]]
name = "tysel-component-sdk"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde",
"serde_json",
@@ -3784,7 +3822,7 @@ dependencies = [
[[package]]
name = "tysel-distribution"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"semver",
"serde",
@@ -3794,8 +3832,9 @@ dependencies = [
[[package]]
name = "tysel-durable"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
+ "getrandom 0.4.3",
"native-tls",
"postgres",
"postgres-native-tls",
@@ -3810,14 +3849,14 @@ dependencies = [
[[package]]
name = "tysel-engine"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"thiserror",
]
[[package]]
name = "tysel-engine-qjs"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"bytes",
"futures-util",
@@ -3851,7 +3890,7 @@ dependencies = [
[[package]]
name = "tysel-engine-wasm"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde_json",
"sha2 0.10.9",
@@ -3866,7 +3905,7 @@ dependencies = [
[[package]]
name = "tysel-ipc"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde",
"serde_json",
@@ -3876,7 +3915,7 @@ dependencies = [
[[package]]
name = "tysel-isolate"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"libc",
"serde_json",
@@ -3891,17 +3930,18 @@ dependencies = [
[[package]]
name = "tysel-manifest"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"toml",
+ "toml_edit",
]
[[package]]
name = "tysel-observability"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"opentelemetry",
"opentelemetry-otlp",
@@ -3913,7 +3953,7 @@ dependencies = [
[[package]]
name = "tysel-package"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"semver",
"serde",
@@ -3924,14 +3964,14 @@ dependencies = [
[[package]]
name = "tysel-policy"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"tysel-capability",
]
[[package]]
name = "tysel-release-signing"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"ed25519-dalek",
@@ -3943,7 +3983,7 @@ dependencies = [
[[package]]
name = "tysel-runtime"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"bytes",
"futures-util",
@@ -3951,6 +3991,7 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
+ "rusqlite",
"serde_json",
"thiserror",
"tokio",
@@ -3977,7 +4018,7 @@ dependencies = [
[[package]]
name = "tysel-scheduler"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"thiserror",
"tysel-task",
@@ -3985,7 +4026,7 @@ dependencies = [
[[package]]
name = "tysel-task"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde_json",
"thiserror",
@@ -3993,7 +4034,7 @@ dependencies = [
[[package]]
name = "tysel-task-rpc"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"serde",
"serde_json",
@@ -4003,7 +4044,7 @@ dependencies = [
[[package]]
name = "tysel-testkit"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"bytes",
@@ -4637,7 +4678,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index c65b460..a90e2db 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,7 +33,7 @@ members = [
]
[workspace.package]
-version = "0.2.0"
+version = "0.3.0"
edition = "2024"
license = "Apache-2.0"
rust-version = "1.95.0"
@@ -46,6 +46,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
clap = { version = "4", features = ["derive"] }
+dialoguer = { version = "0.12", default-features = false }
+json-strip-comments = "3.1.2"
tracing = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "net", "io-util", "process", "signal"] }
# Candidate QuickJS-NG 0.16.2 adapter. Keep the immutable revision until the
diff --git a/README.md b/README.md
index c4197f9..6ec7cb3 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
Get started ·
Examples ·
Documentation ·
+ Changelog ·
Security
@@ -117,7 +118,7 @@ command.
| Toolchain | Linux and macOS, x64 and arm64 |
| Windows | WSL; no native Windows archive yet |
| `service` profile | Trusted first-party application code |
-| `isolated` profile | Separate worker process; Linux is the production security gate |
+| `isolated` profile | Packaged apps also require matching `tysel-worker`; Linux is the production security gate |
| `component` profile | Experimental Wasm Component tasks with restricted WASI |
| Native runtime cross-compilation | Not provided; `build --target` instead packages with a verified same-version official runtime |
diff --git a/benchmarks/comparison/runtimes-tysel-workers-2.json b/benchmarks/comparison/runtimes-tysel-workers-2.json
index f6040d0..3aad250 100644
--- a/benchmarks/comparison/runtimes-tysel-workers-2.json
+++ b/benchmarks/comparison/runtimes-tysel-workers-2.json
@@ -8,7 +8,7 @@
}],
"runtimes": [{
"id": "tysel",
- "expectedVersion": "0.2.0",
+ "expectedVersion": "0.3.0",
"executable": "target/linux-arm64-verify/release/tysel",
"args": ["run", "--manifest", "benchmarks/comparison/adapters/tysel/tysel-workers-2.toml"],
"readinessPrefix": "tysel listen ",
diff --git a/benchmarks/comparison/runtimes-tysel-workers-4.json b/benchmarks/comparison/runtimes-tysel-workers-4.json
index 0a315c9..a5a720d 100644
--- a/benchmarks/comparison/runtimes-tysel-workers-4.json
+++ b/benchmarks/comparison/runtimes-tysel-workers-4.json
@@ -8,7 +8,7 @@
}],
"runtimes": [{
"id": "tysel",
- "expectedVersion": "0.2.0",
+ "expectedVersion": "0.3.0",
"executable": "target/linux-arm64-verify/release/tysel",
"args": ["run", "--manifest", "benchmarks/comparison/adapters/tysel/tysel-workers-4.toml"],
"readinessPrefix": "tysel listen ",
diff --git a/benchmarks/comparison/runtimes.lock.json b/benchmarks/comparison/runtimes.lock.json
index c34d5ec..a865209 100644
--- a/benchmarks/comparison/runtimes.lock.json
+++ b/benchmarks/comparison/runtimes.lock.json
@@ -19,7 +19,7 @@
"runtimes": [
{
"id": "tysel",
- "expectedVersion": "0.2.0",
+ "expectedVersion": "0.3.0",
"executable": "target/release/tysel",
"args": [
"run",
diff --git a/brand/README.md b/brand/README.md
index abb6a33..85a6b81 100644
--- a/brand/README.md
+++ b/brand/README.md
@@ -46,6 +46,11 @@ The secondary `ty` mark is a direct crop of the same outlined wordmark. It is re
## GitHub surfaces
+[`github/tysel-org-avatar.png`](./github/tysel-org-avatar.png) is the upload-ready
+1024×1024 organization profile picture. It keeps the original `ty` mark inside a
+circle-safe area, using Tysel Blue on a solid Runtime Mist field. The editable
+[`SVG source`](./github/tysel-org-avatar.svg) is included alongside it.
+
[`github/tysel-github-banner.png`](./github/tysel-github-banner.png) is the
upload-ready 1280×640 social preview. Its editable
[`SVG source`](./github/tysel-github-banner.svg) is included for future size
diff --git a/brand/github/tysel-org-avatar.png b/brand/github/tysel-org-avatar.png
new file mode 100644
index 0000000..d30419e
Binary files /dev/null and b/brand/github/tysel-org-avatar.png differ
diff --git a/brand/github/tysel-org-avatar.svg b/brand/github/tysel-org-avatar.svg
new file mode 100644
index 0000000..326805b
--- /dev/null
+++ b/brand/github/tysel-org-avatar.svg
@@ -0,0 +1,9 @@
+
diff --git a/crates/tysel-build/src/bundle.rs b/crates/tysel-build/src/bundle.rs
index 00b7ac1..a6b4717 100644
--- a/crates/tysel-build/src/bundle.rs
+++ b/crates/tysel-build/src/bundle.rs
@@ -116,7 +116,7 @@ pub fn bundle(entry: &Path) -> Result<(Vec, Vec)> {
let mut resolved = HashMap::new();
for specifier in &analysis.specifiers {
- let resolved_path = resolve_specifier(&resolver, &path, specifier)?;
+ let resolved_path = resolve_specifier(&resolver, &path, specifier, &source)?;
resolved.insert(specifier.clone(), module_id(&resolved_path));
pending.push_back(resolved_path);
}
@@ -158,6 +158,7 @@ struct OrigPos {
struct Analysis {
has_module_syntax: bool,
specifiers: Vec,
+ specifier_spans: HashMap,
dynamic_imports: Vec,
}
@@ -174,13 +175,37 @@ fn analyze_source(path: &Path, source: &str) -> Result {
)));
}
let mut specifiers = Vec::new();
+ let mut specifier_spans = HashMap::new();
let mut seen = HashSet::new();
+ let mut type_only_statements = HashMap::new();
+ for (start, is_type) in parsed
+ .module_record
+ .import_entries
+ .iter()
+ .map(|entry| (entry.statement_span.start, entry.is_type))
+ .chain(
+ parsed
+ .module_record
+ .indirect_export_entries
+ .iter()
+ .map(|entry| (entry.statement_span.start, entry.is_type)),
+ )
+ {
+ type_only_statements
+ .entry(start)
+ .and_modify(|all: &mut bool| *all &= is_type)
+ .or_insert(is_type);
+ }
for (specifier, requests) in parsed.module_record.requested_modules.iter() {
- if requests.iter().all(|request| request.is_type) {
+ let Some(request) = requests.iter().find(|request| {
+ !request.is_type
+ && type_only_statements.get(&request.statement_span.start) != Some(&true)
+ }) else {
continue;
- }
+ };
let specifier = specifier.as_str().to_string();
if seen.insert(specifier.clone()) {
+ specifier_spans.insert(specifier.clone(), request.span);
specifiers.push(specifier);
}
}
@@ -198,12 +223,14 @@ fn analyze_source(path: &Path, source: &str) -> Result {
let Some(literal) = call.common_js_require() else { continue };
let specifier = literal.value.as_str().to_owned();
if seen.insert(specifier.clone()) {
+ specifier_spans.insert(specifier.clone(), literal.span);
specifiers.push(specifier);
}
}
Ok(Analysis {
has_module_syntax: parsed.module_record.has_module_syntax,
specifiers,
+ specifier_spans,
dynamic_imports: parsed
.module_record
.dynamic_imports
@@ -441,15 +468,45 @@ fn collect_binding(pattern: &BindingPattern<'_>, names: &mut Vec) {
}
}
-fn resolve_specifier(resolver: &Resolver, importer: &Path, specifier: &str) -> Result {
+fn resolve_specifier(
+ resolver: &Resolver,
+ importer: &Path,
+ specifier: &str,
+ source: &str,
+) -> Result {
let directory = importer.parent().unwrap_or(importer);
match resolver.resolve(directory, specifier) {
- Ok(resolution) => canonicalize_existing(&resolution.full_path()),
- Err(ResolveError::Builtin { resolved, .. }) => {
- Err(anyhow!("Node builtin '{resolved}' is not available in Tysel"))
+ Ok(resolution) if !specifier.starts_with("node:") => {
+ canonicalize_existing(&resolution.full_path())
}
- Err(err) => {
- Err(anyhow!("cannot resolve '{}' from {}: {err}", specifier, importer.display()))
+ result => {
+ let (code, message) = match result {
+ Err(ResolveError::Builtin { resolved, .. }) => (
+ "TYSEL_NODE_BUILTIN_UNSUPPORTED",
+ format!("Node builtin '{resolved}' is not available in Tysel"),
+ ),
+ _ if specifier.starts_with("node:") => (
+ "TYSEL_NODE_BUILTIN_UNSUPPORTED",
+ format!("Node builtin '{specifier}' is not available in Tysel"),
+ ),
+ Err(error) => (
+ "TYSEL_IMPORT_UNRESOLVED",
+ format!("cannot resolve '{specifier}' from {}: {error}", importer.display()),
+ ),
+ Ok(_) => unreachable!(),
+ };
+ // Analyze the original source only on failure: successful builds do
+ // not pay for a second parse just to retain diagnostic positions.
+ let original = analyze_source(importer, source)?;
+ let span = original.specifier_spans.get(specifier).copied();
+ Err(anyhow!(crate::BuildDiagnostics::new(vec![crate::BuildDiagnostic::at_source(
+ code,
+ "resolve",
+ message,
+ importer,
+ source,
+ span.map(|span| span.start as usize..span.end as usize),
+ )])))
}
}
}
diff --git a/crates/tysel-build/src/diagnostic.rs b/crates/tysel-build/src/diagnostic.rs
index 01e6422..609d1d4 100644
--- a/crates/tysel-build/src/diagnostic.rs
+++ b/crates/tysel-build/src/diagnostic.rs
@@ -18,6 +18,35 @@ pub struct BuildDiagnostic {
pub end: Option,
}
+impl BuildDiagnostic {
+ /// Create a diagnostic from an original-source byte range, if one exists.
+ pub fn at_source(
+ code: impl Into,
+ phase: impl Into,
+ message: impl Into,
+ path: &Path,
+ source: &str,
+ range: Option>,
+ ) -> Self {
+ let index = LineIndex::new(source);
+ let positions = range.map(|range| {
+ (
+ index.position(source, range.start.min(source.len()) as u32),
+ index.position(source, range.end.min(source.len()) as u32),
+ )
+ });
+ Self {
+ code: code.into(),
+ message: message.into(),
+ severity: DiagnosticSeverity::Error,
+ phase: phase.into(),
+ file: path.to_string_lossy().into_owned(),
+ start: positions.map(|(start, _)| start),
+ end: positions.map(|(_, end)| end),
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
diff --git a/crates/tysel-build/src/lib.rs b/crates/tysel-build/src/lib.rs
index 8336fbd..ba2a5c7 100644
--- a/crates/tysel-build/src/lib.rs
+++ b/crates/tysel-build/src/lib.rs
@@ -693,6 +693,48 @@ export default {
assert!(!js.contains("module.exports = {"));
}
+ #[test]
+ fn nested_import_errors_use_original_typescript_ranges() {
+ let dir = std::env::temp_dir()
+ .join(format!("tysel-nested-import-diagnostic-{}", std::process::id()));
+ fs::create_dir_all(dir.join("src")).unwrap();
+ let entry = dir.join("src/index.ts");
+ fs::write(&entry, "export { default } from './nested.js';\n").unwrap();
+ let nested = dir.join("src/nested.ts");
+ for specifier in ["node:fs", "node:future_builtin", "./missing.js"] {
+ // Type erasure changes the generated line/column before this import.
+ let source = format!(
+ "type Name = string;\nconst name: Name = '王😀'; import '{specifier}';\nexport default name;\n"
+ );
+ fs::write(&nested, &source).unwrap();
+ let error = read_bundle(&entry).unwrap_err();
+ let diagnostics = error.downcast_ref::().unwrap();
+ let diagnostic = &diagnostics.diagnostics()[0];
+ assert_eq!(
+ diagnostic.code,
+ if specifier.starts_with("node:") {
+ "TYSEL_NODE_BUILTIN_UNSUPPORTED"
+ } else {
+ "TYSEL_IMPORT_UNRESOLVED"
+ }
+ );
+ assert_eq!(Path::new(&diagnostic.file), fs::canonicalize(&nested).unwrap());
+ let start = diagnostic.start.unwrap();
+ let end = diagnostic.end.unwrap();
+ assert_eq!(start.line, 2);
+ let selected = &source[start.byte_offset as usize..end.byte_offset as usize];
+ assert!(selected.contains(specifier), "{selected:?}");
+ let line_start = source.find('\n').unwrap() + 1;
+ assert_eq!(
+ start.column as usize,
+ source[line_start..start.byte_offset as usize].chars().count() + 1
+ );
+ }
+ fs::write(&nested, "import type { Stats } from 'node:fs';\nimport { type Missing } from './not-installed.js';\nexport default {};\n").unwrap();
+ assert!(read_bundle(&entry).is_ok(), "erased imports must not be resolved");
+ fs::remove_dir_all(dir).unwrap();
+ }
+
#[test]
fn node_builtins_are_rejected() {
let dir = std::env::temp_dir().join(format!("tysel-build-fs-{}", std::process::id()));
diff --git a/crates/tysel-build/src/runtime-components.json b/crates/tysel-build/src/runtime-components.json
index 3f2d027..624e49b 100644
--- a/crates/tysel-build/src/runtime-components.json
+++ b/crates/tysel-build/src/runtime-components.json
@@ -1,6 +1,6 @@
{
"inventory_version": 1,
- "cargo_lock_sha256": "1f7d8b76e2f5e4330473ff013afae3520afafec95f30ec42185bcf76fe6a99dd",
+ "cargo_lock_sha256": "001add1e9594e03c8b8d168ca98df5d5eaf323b5c8be6f012accfecb9b382bde",
"roots": [
"tysel-cli",
"tysel-isolate",
@@ -391,6 +391,14 @@
"source": "registry+https://github.com/rust-lang/crates.io-index",
"checksum": "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
},
+ {
+ "name": "console",
+ "version": "0.16.4",
+ "license": "MIT",
+ "purl": "pkg:cargo/console@0.16.4",
+ "source": "registry+https://github.com/rust-lang/crates.io-index",
+ "checksum": "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+ },
{
"name": "const-oid",
"version": "0.10.2",
@@ -615,6 +623,14 @@
"source": "registry+https://github.com/rust-lang/crates.io-index",
"checksum": "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
},
+ {
+ "name": "dialoguer",
+ "version": "0.12.0",
+ "license": "MIT",
+ "purl": "pkg:cargo/dialoguer@0.12.0",
+ "source": "registry+https://github.com/rust-lang/crates.io-index",
+ "checksum": "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
+ },
{
"name": "digest",
"version": "0.10.7",
@@ -687,6 +703,14 @@
"source": "registry+https://github.com/rust-lang/crates.io-index",
"checksum": "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
},
+ {
+ "name": "encode_unicode",
+ "version": "1.0.0",
+ "license": "Apache-2.0 OR MIT",
+ "purl": "pkg:cargo/encode_unicode@1.0.0",
+ "source": "registry+https://github.com/rust-lang/crates.io-index",
+ "checksum": "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+ },
{
"name": "encoding_rs",
"version": "0.8.35",
@@ -2460,6 +2484,14 @@
"source": "registry+https://github.com/rust-lang/crates.io-index",
"checksum": "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
},
+ {
+ "name": "shell-words",
+ "version": "1.1.1",
+ "license": "MIT OR Apache-2.0",
+ "purl": "pkg:cargo/shell-words@1.1.1",
+ "source": "registry+https://github.com/rust-lang/crates.io-index",
+ "checksum": "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+ },
{
"name": "shlex",
"version": "2.0.1",
@@ -2862,184 +2894,184 @@
},
{
"name": "tysel-build",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-build@0.2.0",
+ "purl": "pkg:cargo/tysel-build@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-fs",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-fs@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-fs@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-llm",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-llm@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-llm@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-mcp",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-mcp@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-mcp@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-postgres",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-postgres@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-postgres@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-redis",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-redis@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-redis@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cap-sqlite",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cap-sqlite@0.2.0",
+ "purl": "pkg:cargo/tysel-cap-sqlite@0.3.0",
"source": "workspace"
},
{
"name": "tysel-capability",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-capability@0.2.0",
+ "purl": "pkg:cargo/tysel-capability@0.3.0",
"source": "workspace"
},
{
"name": "tysel-cli",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-cli@0.2.0",
+ "purl": "pkg:cargo/tysel-cli@0.3.0",
"source": "workspace"
},
{
"name": "tysel-distribution",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-distribution@0.2.0",
+ "purl": "pkg:cargo/tysel-distribution@0.3.0",
"source": "workspace"
},
{
"name": "tysel-durable",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-durable@0.2.0",
+ "purl": "pkg:cargo/tysel-durable@0.3.0",
"source": "workspace"
},
{
"name": "tysel-engine-qjs",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-engine-qjs@0.2.0",
+ "purl": "pkg:cargo/tysel-engine-qjs@0.3.0",
"source": "workspace"
},
{
"name": "tysel-engine-wasm",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-engine-wasm@0.2.0",
+ "purl": "pkg:cargo/tysel-engine-wasm@0.3.0",
"source": "workspace"
},
{
"name": "tysel-engine",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-engine@0.2.0",
+ "purl": "pkg:cargo/tysel-engine@0.3.0",
"source": "workspace"
},
{
"name": "tysel-ipc",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-ipc@0.2.0",
+ "purl": "pkg:cargo/tysel-ipc@0.3.0",
"source": "workspace"
},
{
"name": "tysel-isolate",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-isolate@0.2.0",
+ "purl": "pkg:cargo/tysel-isolate@0.3.0",
"source": "workspace"
},
{
"name": "tysel-manifest",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-manifest@0.2.0",
+ "purl": "pkg:cargo/tysel-manifest@0.3.0",
"source": "workspace"
},
{
"name": "tysel-observability",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-observability@0.2.0",
+ "purl": "pkg:cargo/tysel-observability@0.3.0",
"source": "workspace"
},
{
"name": "tysel-package",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-package@0.2.0",
+ "purl": "pkg:cargo/tysel-package@0.3.0",
"source": "workspace"
},
{
"name": "tysel-policy",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-policy@0.2.0",
+ "purl": "pkg:cargo/tysel-policy@0.3.0",
"source": "workspace"
},
{
"name": "tysel-release-signing",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-release-signing@0.2.0",
+ "purl": "pkg:cargo/tysel-release-signing@0.3.0",
"source": "workspace"
},
{
"name": "tysel-runtime",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-runtime@0.2.0",
+ "purl": "pkg:cargo/tysel-runtime@0.3.0",
"source": "workspace"
},
{
"name": "tysel-scheduler",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-scheduler@0.2.0",
+ "purl": "pkg:cargo/tysel-scheduler@0.3.0",
"source": "workspace"
},
{
"name": "tysel-task-rpc",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-task-rpc@0.2.0",
+ "purl": "pkg:cargo/tysel-task-rpc@0.3.0",
"source": "workspace"
},
{
"name": "tysel-task",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-task@0.2.0",
+ "purl": "pkg:cargo/tysel-task@0.3.0",
"source": "workspace"
},
{
"name": "tysel-testkit",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "Apache-2.0",
- "purl": "pkg:cargo/tysel-testkit@0.2.0",
+ "purl": "pkg:cargo/tysel-testkit@0.3.0",
"source": "workspace"
},
{
diff --git a/crates/tysel-cli/Cargo.toml b/crates/tysel-cli/Cargo.toml
index 3b22c20..ebabec5 100644
--- a/crates/tysel-cli/Cargo.toml
+++ b/crates/tysel-cli/Cargo.toml
@@ -18,6 +18,8 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
+dialoguer.workspace = true
+json-strip-comments.workspace = true
tokio.workspace = true
tysel-distribution.workspace = true
tysel-manifest.workspace = true
@@ -29,6 +31,7 @@ tysel-observability.workspace = true
tysel-capability.workspace = true
tysel-cap-mcp.workspace = true
tysel-task-rpc.workspace = true
+tysel-task.workspace = true
tysel-package.workspace = true
tysel-testkit = { path = "../tysel-testkit" }
notify = "6"
@@ -40,4 +43,5 @@ libc.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }
[dev-dependencies]
+tysel-durable = { path = "../tysel-durable" }
wat = "1"
diff --git a/crates/tysel-cli/src/build.rs b/crates/tysel-cli/src/build.rs
index f100b21..a4c9825 100644
--- a/crates/tysel-cli/src/build.rs
+++ b/crates/tysel-cli/src/build.rs
@@ -98,6 +98,11 @@ pub fn run(manifest_path: PathBuf, options: Options) -> Result<()> {
println!("{payload_label:<16} {}", format_bytes(payload_bytes as u64));
println!("Capabilities {}", capability_summary(&manifest));
println!("Runtime {}", manifest.app.profile);
+ if manifest.app.profile == "isolated" {
+ println!(
+ "Worker matching tysel-worker required beside executable (or TYSEL_WORKER)"
+ );
+ }
println!("Executable {}", format_bytes(executable));
println!("Target {}", target.canonical());
println!("Output {}", output.display());
diff --git a/crates/tysel-cli/src/check.rs b/crates/tysel-cli/src/check.rs
index 0245df7..e45f160 100644
--- a/crates/tysel-cli/src/check.rs
+++ b/crates/tysel-cli/src/check.rs
@@ -5,6 +5,14 @@ use anyhow::{Context, Result, anyhow};
use tysel_manifest::Manifest;
pub fn run(manifest_path: &Path) -> Result<()> {
+ run_with_options(manifest_path, false)
+}
+
+pub(crate) fn run_requiring_types(manifest_path: &Path) -> Result<()> {
+ run_with_options(manifest_path, true)
+}
+
+fn run_with_options(manifest_path: &Path, require_types: bool) -> Result<()> {
let manifest = Manifest::from_path(manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let root = manifest_path.parent().unwrap_or(Path::new("."));
@@ -33,21 +41,20 @@ pub fn run(manifest_path: &Path) -> Result<()> {
Typecheck::Skipped(reason) => println!(" types skipped ({reason})"),
Typecheck::Failed(_) => println!(" types fail"),
}
- if let Typecheck::Failed(output) = types {
- eprint!("{output}");
- return Err(anyhow!("TypeScript check failed"));
+ match types {
+ Typecheck::Failed(output) => {
+ eprint!("{output}");
+ return Err(anyhow!("TypeScript check failed"));
+ }
+ Typecheck::Skipped(reason) if require_types => {
+ return Err(anyhow!("TypeScript check was skipped: {reason}"));
+ }
+ Typecheck::Ok | Typecheck::Skipped(_) => {}
}
if !is_component {
- let specifiers = crate::node_scan::scan_file(&entry)?;
- if specifiers.is_empty() {
- println!(" node ok");
- } else {
- println!(" node fail");
- for specifier in &specifiers {
- eprintln!("unsupported Node builtin '{specifier}'");
- }
- return Err(anyhow!("Node builtins are not available in Tysel"));
- }
+ // The build resolver validates runtime imports across the entire graph.
+ // Re-scanning raw TS here would incorrectly reject erased type imports.
+ println!(" node ok");
}
Ok(())
}
@@ -99,3 +106,35 @@ fn find_tsc(start: &Path) -> Option {
}
None
}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use super::*;
+
+ #[test]
+ fn strict_check_rejects_a_skipped_typecheck() {
+ let root = std::env::temp_dir().join(format!(
+ "tysel-strict-check-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
+ ));
+ fs::create_dir_all(root.join("src")).unwrap();
+ fs::write(
+ root.join("tysel.toml"),
+ "[app]\nname = \"strict-check\"\nentry = \"src/index.ts\"\n",
+ )
+ .unwrap();
+ fs::write(
+ root.join("src/index.ts"),
+ "export default { fetch() { return new Response('ok'); } };\n",
+ )
+ .unwrap();
+ fs::write(root.join("tsconfig.json"), "{}\n").unwrap();
+
+ let error = run_requiring_types(&root.join("tysel.toml")).unwrap_err();
+ assert!(error.to_string().contains("TypeScript check was skipped"));
+ fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/crates/tysel-cli/src/dev.rs b/crates/tysel-cli/src/dev.rs
index b2b1313..457dc9d 100644
--- a/crates/tysel-cli/src/dev.rs
+++ b/crates/tysel-cli/src/dev.rs
@@ -18,8 +18,8 @@ use tysel_package::SourceMap;
#[cfg(unix)]
use tysel_runtime::ModuleTaskService;
use tysel_runtime::{
- AppIsolate, DurablePlane, HttpLimits, RuntimeDiagnostic, SharedPool, handle_stream,
- spawn_app_isolate,
+ AppIsolate, DurablePlane, HttpLimits, HttpShutdown, RuntimeDiagnostic, SharedPool,
+ handle_stream, spawn_app_isolate,
};
use tysel_task_rpc::TaskOutcome;
@@ -214,17 +214,23 @@ async fn serve(
reload: bool,
error_format: ErrorFormat,
) -> Result<()> {
- let loaded = load(&manifest_path, entry.as_deref())?;
- let pool = SharedPool::with_server_limits(
+ let loaded = load_for_serve(&manifest_path, entry.as_deref(), reload)?;
+ let shutdown = HttpShutdown::default();
+ let mut grace = loaded.isolate.request_timeout().saturating_add(Duration::from_secs(1));
+ let pool = SharedPool::with_http_limits(
loaded.isolate,
- loaded.max_request_bytes,
- loaded.max_in_flight,
+ HttpLimits {
+ max_request_bytes: loaded.max_request_bytes,
+ max_response_bytes: loaded.max_response_bytes,
+ max_in_flight: loaded.max_in_flight,
+ },
loaded.websocket,
loaded.http1,
loaded.http2,
- Some(loaded.source_map),
- );
- if error_format == ErrorFormat::Json {
+ reload.then_some(loaded.source_map),
+ )
+ .with_shutdown(shutdown.clone());
+ if reload && error_format == ErrorFormat::Json {
attach_runtime_diagnostics(&pool)?;
}
let listener =
@@ -238,21 +244,22 @@ async fn serve(
}
print!("{}", listen_announcement(bound));
io::stdout().flush()?;
- if reload {
+ let result = if reload {
report_diagnostics_clear(error_format, 0);
let mut changes = watch(manifest_path.parent().unwrap_or(Path::new(".")))?;
let mut diagnostic_generation = 0u64;
loop {
tokio::select! {
- _ = tokio::signal::ctrl_c() => break,
+ signal = shutdown_signal() => break signal.map_err(Into::into),
+ error = durable_service_failure(durable.as_ref()) => {
+ break Err(error);
+ }
error = task_service_failure(task_service.as_ref()) => {
- shutdown_durable(durable.take()).await?;
- shutdown_task_service(task_service).await?;
- return Err(error);
+ break Err(error);
}
_ = wait_change(&mut changes.rx) => {
diagnostic_generation = diagnostic_generation.saturating_add(1);
- match load(&manifest_path, entry.as_deref()) {
+ match load_for_serve(&manifest_path, entry.as_deref(), true) {
Ok(next) => {
task_generation = task_generation.saturating_add(1);
match start_task_service(next.task.clone(), task_generation).await {
@@ -263,6 +270,7 @@ async fn serve(
if error_format == ErrorFormat::Human {
eprintln!("tysel reload");
}
+ grace = grace.max(next.isolate.request_timeout().saturating_add(Duration::from_secs(1)));
pool.replace_with_server_limits(
next.isolate,
HttpLimits {
@@ -289,32 +297,53 @@ async fn serve(
Err(err) => report_dev_error(error_format, diagnostic_generation, &err),
}},
accepted = listener.accept() => {
- let (stream, _) = accepted.context("accept")?;
+ let (stream, _) = match accepted { Ok(value) => value, Err(error) => break Err(error.into()) };
handle_stream(stream, pool.clone());
}
}
}
- shutdown_durable(durable.take()).await?;
- shutdown_task_service(task_service).await?;
- return Ok(());
- }
- loop {
- tokio::select! {
- _ = tokio::signal::ctrl_c() => break,
- error = task_service_failure(task_service.as_ref()) => {
- shutdown_durable(durable.take()).await?;
- shutdown_task_service(task_service).await?;
- return Err(error);
- }
- accepted = listener.accept() => {
- let (stream, _) = accepted.context("accept")?;
- handle_stream(stream, pool.clone());
+ } else {
+ loop {
+ tokio::select! {
+ signal = shutdown_signal() => break signal.map_err(Into::into),
+ error = durable_service_failure(durable.as_ref()) => {
+ break Err(error);
+ }
+ error = task_service_failure(task_service.as_ref()) => {
+ break Err(error);
+ }
+ accepted = listener.accept() => {
+ let (stream, _) = match accepted { Ok(value) => value, Err(error) => break Err(error.into()) };
+ handle_stream(stream, pool.clone());
+ }
}
}
+ };
+ drop(listener);
+ shutdown.cancel();
+ if let Some(plane) = &durable {
+ plane.stop_claiming();
}
- shutdown_durable(durable.take()).await?;
- shutdown_task_service(task_service).await?;
- Ok(())
+ shutdown.drain(grace).await;
+ let durable_result = shutdown_durable(durable.take()).await;
+ let task_result = shutdown_task_service(task_service).await;
+ result?;
+ durable_result?;
+ task_result
+}
+
+#[cfg(unix)]
+async fn shutdown_signal() -> io::Result<()> {
+ let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
+ tokio::select! {
+ result = tokio::signal::ctrl_c() => result,
+ _ = terminate.recv() => Ok(()),
+ }
+}
+
+#[cfg(not(unix))]
+async fn shutdown_signal() -> io::Result<()> {
+ tokio::signal::ctrl_c().await
}
fn attach_runtime_diagnostics(pool: &SharedPool) -> Result<()> {
@@ -456,9 +485,27 @@ fn listen_announcement(bound: std::net::SocketAddr) -> String {
format!("tysel listen {bound}\ntysel url http://{bound}\n")
}
+fn load_for_serve(manifest_path: &Path, entry: Option<&Path>, sync_types: bool) -> Result {
+ let manifest = Manifest::from_path(manifest_path)
+ .with_context(|| format!("failed to read {}", manifest_path.display()))?;
+ if sync_types {
+ crate::typegen::sync(
+ manifest_path.parent().unwrap_or(Path::new(".")),
+ &manifest,
+ None,
+ false,
+ )?;
+ }
+ load_manifest(manifest_path, entry, manifest)
+}
+
fn load(manifest_path: &Path, entry: Option<&Path>) -> Result {
let manifest = Manifest::from_path(manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
+ load_manifest(manifest_path, entry, manifest)
+}
+
+fn load_manifest(manifest_path: &Path, entry: Option<&Path>, manifest: Manifest) -> Result {
let root = manifest_path.parent().unwrap_or(Path::new("."));
let entry = match entry {
Some(path) => path.to_path_buf(),
@@ -608,28 +655,42 @@ async fn shutdown_task_service(_service: Option<()>) -> Result<()> {
}
async fn start_dev_durable(spec: Option) -> Result