diff --git a/integrations/wasm/go/examples/basic_app/.angular-ts/bootstrap.js b/integrations/wasm/go/examples/basic_app/.angular-ts/bootstrap.js index 0df6a1915..3b77530be 100644 --- a/integrations/wasm/go/examples/basic_app/.angular-ts/bootstrap.js +++ b/integrations/wasm/go/examples/basic_app/.angular-ts/bootstrap.js @@ -1,5 +1,5 @@ import { angular } from "@angular-wave/angular.ts"; -import { WasmScopeAbi } from "@angular-wave/angular.ts/services/wasm"; +import { WasmAbi } from "@angular-wave/angular.ts/services/wasm"; import { GoWasmScopeAbi } from "../go-wasm-scope-abi.js"; import "../wasm_exec.js"; @@ -7,7 +7,7 @@ const moduleName = "goWasmTodo"; const requires = []; const manifest = {"registrations":[{"kind":"controller","name":"goTodoController","export":"__ng_controller_GoTodoController","scopeName":"goTodo:main","methods":["add","toggle","archive"],"fields":[{"name":"items","goName":"items","goType":"[]main.Todo"},{"name":"remainingCount","goName":"remainingCount()","goType":"int"},{"name":"newTodo","goName":"newTodo","goType":"string"},{"name":"titleSeen","goName":"titleSeen","goType":"string"}],"watches":[{"path":"newTodo","handler":"onNewTodoChanged","goType":"string"}]}]}; -const scopeAbi = new WasmScopeAbi(); +const scopeAbi = new WasmAbi.ScopeAbi(); const goScopeAbi = new GoWasmScopeAbi(scopeAbi); globalThis.__angularTsGoWasmScopeAbi = goScopeAbi; diff --git a/src/DESIGN_PHILOSOPHY.md b/src/DESIGN_PHILOSOPHY.md index 7ca72a2f4..06ff5ac0e 100644 --- a/src/DESIGN_PHILOSOPHY.md +++ b/src/DESIGN_PHILOSOPHY.md @@ -36,12 +36,17 @@ declarative and safer. AngularTS APIs should make the user's intent obvious. +Machine, workflow, and router APIs follow the durable API ergonomics contract +below. Public types belong in the `ng` namespace only when users encounter and +need documentation for them. + Preferred user paths: - declare app structure through `NgModule` - declare shared reactive state through `app.model(...)` - declare service config through service-owned config -- declare third-party modules through `app.use(plugin, config)` +- declare third-party modules through `angular.module(...)` and module + dependencies - declare operational behavior through policies - consume runtime services through injection @@ -165,6 +170,109 @@ Public surface should avoid: Compatibility can preserve old paths during migration, but documentation should teach the stable user path first. +## API Ergonomics Contract + +Machine, workflow, and router ergonomics are governed by a no-new-runtime-API +rule: simplify authoring, inference, naming, documentation, and exported types +before adding another runtime method, helper, directive, or test API. + +The common authoring path starts from one object literal and does not require +explicit generic parameters. TypeScript should infer state names, route names, +event names, command names, payloads, params, and resolves from that literal +and preserve them through named module registration. + +### Runtime Vocabulary + +| Name | Meaning | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `send` | Deliver a typed event to a machine or workflow state engine. It does not start workflow commands or router navigation. | +| `run` | Start a named workflow command and return its asynchronous command result. | +| `go` | Navigate to a named router state through `$state.go(...)`. | +| `transition` | The router navigation lifecycle and its evidence object. Machine and workflow event delivery uses `send(...)`; application navigation uses `$state.go(...)`. | +| `status` | A stable result or runtime-state discriminator intended for application control flow. | +| `snapshot` | Capture documented durable evidence without executable callbacks, adapters, or framework internals. | +| `restore` | Apply a compatible snapshot to an existing runtime object while preserving its reactive identity. | + +Different verbs are intentional. `machine.send(...)` handles an event, +`workflow.run(...)` starts a command, and `$state.go(...)` starts navigation. +Using one generic dispatch verb for all three would erase meaningful lifecycle +and result differences. + +### Outcomes And Evidence + +| Boundary | Control flow | Evidence | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `machine.send(...)` | Branch on `ok`, then use `status` to distinguish `transitioned`, `updated`, missing, invalid, guard-denied, or policy-denied outcomes. | The result describes the attempted transition. Machines do not accumulate diagnostics. | +| `workflow.run(...)` | Branch on `ok`, then use settled `status` values: `completed`, `failed`, `cancelled`, `timeout`, or `rejected`. Queueing is execution policy, not a returned outcome. | Diagnostics and bounded history explain failures and support retry, repeat, snapshots, and recovery. | +| `$state.go(...)` | Await the `TransitionPromise`; fulfillment and rejection are the asynchronous navigation branches. | The attached `Transition` records success, error, redirects, and lifecycle detail after settlement. | +| `policy.check(...)` | Branch on the decision `type`. Common gate policies use `allow`, `deny`, and `redirect`; specialized policies use domain terms such as `deliver`, `drop`, or cache strategies. | Optional reason, status, target, error, and metadata explain the decision. | + +Diagnostics are evidence, not a substitute for `ok`, `status`, Promise +settlement, or policy decision `type`. Specialized policy decision types remain +separate because a cache strategy and an authorization gate do not have the +same semantics. + +Snapshots contain durable state and evidence only. They must not contain live +scopes, callbacks, adapters, promises, abort controllers, DOM nodes, or runtime +registries. Restore applies snapshot state to an existing runtime; it does not +revive in-flight commands, hooks, retained views, or external resources. + +### Public Type Placement + +The ambient `ng` namespace contains types users encounter while authoring an +app or consuming injected runtime objects: declarations, config, callback +arguments that need explicit annotation, runtime services, results, statuses, +snapshots, diagnostics, and supported extension contracts. + +The main package entry point contains the ordinary authoring functions and the +runtime/evidence types needed by the common path. Advanced adapters, protocol +messages, mapped inference helpers, and reusable package-author contracts use +direct module imports. Implementation-only mapped types, registries, provider +recipes, and proxy machinery are not public exports. + +A type is not hidden merely because its name is verbose. Return values, config +values, callback arguments, snapshots, results, diagnostics, and extension +contracts remain documented wherever users encounter or implement them. + +### Registration Inference + +| Registration | Inference rule | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app.machine(name, definition)` | Preserve data, event, payload, and state-name information from the machine definition or injectable definition factory. | +| `app.workflow(name, definition)` | Preserve data, event, command, command input/output, and state-name information from the workflow definition or injectable definition factory. | +| `module.router(tree)` | Infer the route map from the literal route tree and return a typed router module carrying that map. | +| `module.router(...)` | Preserve the route map already carried by a typed router module and reject names, params, or resolves outside that map. A route tree registered on an untyped module infers its contract from the declaration. | +| `module.lazyState(prefix, loader)` | Preserve the typed router module's route map and constrain the lazy prefix to a declared lazy boundary. The loader does not silently widen the known route map. | + +Named registration must not make ordinary users repeat generic arguments that +were available at the declaration site. + +### Policy Attachment + +Policy uses existing config ownership boundaries: + +| Primitive | Attachment point | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Machine | Transition admission policy is the machine definition's `policy` field; guards and hooks stay on the transition or machine definition that owns them. | +| Workflow | Command timeout and concurrency stay on `WorkflowConfig` or per-run options. Persistence and recovery stay on `WorkflowSupervisorConfig` through `persistence`, `persistencePolicy`, and `recovery`. | +| Router | App-wide defaults stay in `$router` config. Route-subtree navigation, transition, and retention policy stays under `StateDeclaration.policy` and inherits through the route tree. | + +Machine, workflow, and router do not gain a second generic policy registry. +Configuration remains declarative and local to the primitive that executes the +decision. + +### State Terminology + +A machine or workflow `state` is an execution mode in that primitive's local +`states` map. A router state is a named navigation node with views, params, +resolves, and URL behavior. A route tree is the hierarchical module declaration +made from router states. + +Router documentation retains “state” because it is the established router +runtime term (`$state`, `StateDeclaration`, and `$transitions`). It uses “route +tree” for module-level hierarchy and always says “router state” where confusion +with machine or workflow state is plausible. + ## Type Safety Configuration and extension APIs should be type-safe. @@ -173,7 +281,7 @@ Type safety should: - reject unknown config keys - reject invalid config fields -- infer model and plugin config types +- infer model and module-local config types - prevent root-scoped dependencies from leaking into app-owned factories where possible - avoid exposing internal implementation types just to make the compiler happy @@ -190,8 +298,9 @@ A stable AngularTS API needs: - generated public docs - a clear service or concept guide - executable or testable samples +- 100% test coverage for new modules - migration notes when replacing older paths -- public type inventory classification +- a named, documented contract for every public injectable - clear ownership and lifecycle guidance If users can see or import a public API, they should be able to find what it is @@ -211,6 +320,17 @@ Before adding or keeping an API, ask: - does this expose only the types users need? - can it be documented with an executable or testable sample? +For security policy features, add only when all of these are true: + +- the behavior is truly cross-cutting across more than one domain (routing, + request stack, realtime, service worker, etc.); +- incorrect usage creates a meaningful security risk (permissions, secret + leakage, privilege escalation, or trust-boundary failure); +- a deterministic centralized policy removes repeated app-specific authorization and + headers/guard logic; +- safe defaults keep apps secure out of the box, with explicit opt-in for riskier + behavior. + If the answer is no, the feature should be redesigned, deferred, or kept internal. @@ -230,4 +350,5 @@ At that point: - public API surface matches user needs - provider internals remain available to the injector but disappear from the normal authoring path +- new modules target 100% test coverage before being considered mature - docs and generated outputs teach the same stable model diff --git a/src/DOCUMENTATION_REQUIREMENT.md b/src/DOCUMENTATION_REQUIREMENT.md index 1ee1dc367..d0f83ae6a 100644 --- a/src/DOCUMENTATION_REQUIREMENT.md +++ b/src/DOCUMENTATION_REQUIREMENT.md @@ -21,7 +21,7 @@ This requirement applies to: - renamed public types - changed service config - changed `NgModule` authoring methods -- changed plugin APIs +- changed module extension APIs - changed runtime service behavior - provider-to-config migrations @@ -30,17 +30,13 @@ not affect generated types, docs, examples, or user-visible behavior. ## Required Documentation For Every Public Change -1. Public type inventory - - - Add or update the entry in `src/PUBLIC_API_SURFACE_INVENTORY.md`. - - Classify the symbol as authoring, runtime, config, callback, extension, - evidence, provider, internal, or legacy. - - Record the user need: author, inject, configure, implement, consume, or - none. - - Record the docs page and sample path. +1. Public contract + - Export a named type through `src/namespace.ts` when users encounter it. + - Export the same contract through `src/docs.ts` for generated docs. + - Do not expose provider recipes or implementation-only protocol types. + - Add the relevant user guide and sample path in the owning module docs. 2. Generated type surface - - Regenerate declaration files when public TypeScript surface changes. - Regenerate integration surfaces when generated bindings track the public namespace. @@ -48,14 +44,12 @@ not affect generated types, docs, examples, or user-visible behavior. generated parity by accident. 3. Generated docs - - Public symbols must have enough JSDoc or TypeDoc context to be useful in `docs/static/typedoc`. - Generated docs must be refreshed whenever exported public types change. - Hidden/internal symbols must not appear as recommended user-facing API. 4. User guidance - - Add or update the relevant page under `docs/content`. - Explain the stable user path, not the internal provider path. - Put config beside the runtime service it configures. @@ -63,16 +57,18 @@ not affect generated types, docs, examples, or user-visible behavior. type. 5. Executable or testable samples - - Every new public API needs at least one sample in `docs/content` or `docs/static/examples`. - Snippets must be validated by `make docs-examples-check`. - Multi-step flows should live in `docs/static/examples` so they can be promoted to runnable checks. - Behavioral APIs need source tests in addition to docs samples. + - New modules must target 100% test coverage before they are considered + level-9 complete. + - Any temporary coverage gap for a new module must be documented in its + pull request with a reason, owner, and closing condition. 6. Migration coverage - - Every deprecated public path needs a replacement path or explicit removal rationale. - Provider-to-config migrations must show the old path and new path. @@ -96,6 +92,10 @@ make check make doc ``` +For new modules, add coverage verification for the module and document the +result in the pull request. The target is 100% coverage for the new module's +owned files. + For docs-only changes that include examples: ```bash @@ -103,43 +103,26 @@ make docs-examples-check make doc ``` -## Future Documentation Tooling - -The current repository already has `make generated-check`, -`make docs-examples-check`, and `make doc`. The refactor should add stricter -documentation tooling in small slices: - -1. Add a docs requirement target +For a complete level-9 documentation refresh: - Candidate target: - - ```bash - make docs-requirement - ``` - - It should give maintainers one command for generated surface checks, docs - example checks, and TypeDoc generation. It is a convenience target, not a - statement that CI must require it. - -2. Add a public-type documentation check - - The check should report when a curated public type has no matching TypeDoc - page or no inventory entry. - -3. Add sample coverage metadata - - Public inventory entries should include a docs sample path. Tooling should - report public user-facing types that lack at least one sample. - -4. Promote runnable examples +```bash +make docs-requirement +``` - Complex examples under `docs/static/examples` should become Playwright or - TypeScript fixtures instead of passive snippets. +This target runs generated surface checks, docs example checks, and TypeDoc +generation. It is a maintainer convenience target for the documentation +requirement, not a CI mandate. -5. Add migration-note checks +## Documentation Tooling - Deprecated provider paths and removed public types should require migration - notes before namespace changes land. +- `make docs-requirement` refreshes generated surfaces, validates examples, + generates TypeDoc, and checks public type documentation. +- `make public-type-docs-check` derives the current public surface from + `src/namespace.ts` and requires a generated TypeDoc page for every export. +- `make docs-examples-check` validates API references used by documentation + examples. +- Complex examples under `docs/static/examples` should become Playwright or + TypeScript fixtures instead of passive snippets. ## Level-9 Documentation Completeness diff --git a/src/GLOBAL_LEVEL_9_ROADMAP.md b/src/GLOBAL_LEVEL_9_ROADMAP.md deleted file mode 100644 index 7231c8640..000000000 --- a/src/GLOBAL_LEVEL_9_ROADMAP.md +++ /dev/null @@ -1,406 +0,0 @@ -# Global Level-9 Roadmap - -This roadmap coordinates the public API, service config, policy hardening, and -provider-surface work needed to move AngularTS toward level-9 maturity. - -The goal is not to expand API for its own sake. The goal is to make AngularTS a -single declarative Web Platform primitive: users declare structure, config, and -policies; AngularTS owns browser mechanics, lifecycle, recovery, reactivity, -and dependency-replacement boundaries. - -## Related Roadmaps - -- `src/DESIGN_PHILOSOPHY.md`: AngularTS doctrine and decision filter for - public API, reactivity, policy, ownership, and documentation. -- `src/PUBLIC_API_SURFACE_ROADMAP.md`: curated public namespace and exposed - type shrinkage. -- `src/core/di/PROVIDER_SURFACE_ROADMAP.md`: provider classification, - migration, and internal recipe redesign. -- `src/core/scope/APPCONTEXT_IMPLEMENTATION_ROADMAP.md`: primary app-owned - reactivity refactor that separates runtime reactivity from `$rootScope` and - DOM scopes. -- `src/services/SERVICE_POLICY_REAPPLICATION_ROADMAP.md`: service config, - policy hardening, reactivity, failure, and recovery backfill. -- `src/core/di/ng-module/MODEL_IMPLEMENTATION_ROADMAP.md`: `app.model(...)` - reactive model service primitive. -- `src/DOCUMENTATION_REQUIREMENT.md`: level-9 documentation requirement for - every changed public API. - -## Execution Principle - -Do not start with a broad provider redesign. Provider redesign should follow -evidence from real service slices. - -Do not harden services randomly either. Service hardening must happen through -vertical module slices after the global public-surface decisions are made. - -The implementation order is: - -1. Introduce app-owned reactivity through internal `AppContext`. -2. Make global public-surface decisions. -3. Execute vertical module slices. -4. Redesign provider internals after patterns are proven. -5. Clean up the public surface in a compatibility window. - -## Phase 0: App-Owned Reactivity First - -The first primary refactor is `AppContext`. - -`$rootScope` should remain the root of UI scopes, but it should not own the -entire reactive runtime. Reactivity must be detached from DOM and elements so -models, machines, workflows, workers, realtime connections, and browser service -state can exist as app-owned primitives. - -`AppContext` remains singleton and can manage multiple bootstrapped -`$rootScope` trees. Models registered with the root app context are visible to -all root scopes managed by that context. -Those models survive destruction of root contexts while `AppContext` remains -alive, and newly attached roots can observe the surviving models. - -See: - -```text -src/core/scope/APPCONTEXT_IMPLEMENTATION_ROADMAP.md -``` - -Acceptance: - -- app context owns `$rootScope` creation -- app context can own multiple `$rootScope` records -- context-wide models are visible to every root scope managed by the context -- context-wide models survive root destruction and can be observed by later - roots attached to the same context -- destroying a root removes that root's observations without destroying - context-wide model data -- app context exposes internal root attach/destroy hooks and generation - metadata for diagnostics and service integration -- DOM-related schedulers are root-owned -- model schedulers are app-context-owned -- root-owned scheduler failures are isolated from sibling roots -- app context can exist and schedule model work with zero root records -- app-owned reactive proxies can exist without DOM or elements -- `$rootScope` remains the UI scope root -- scope event propagation remains compatible -- downstream services are classified as app-owned, view-owned, or not - applicable -- Wasm integrations keep root-owned bridges for template/scope work and - app-owned modules for non-DOM runtime work -- `app.model(...)` can be implemented as an app-owned reactive service -- `app.model(...)` does not reserve framework keys on user model objects - -## Phase 1: Global Decisions - -These decisions unblock every later slice. They should be documented before -runtime behavior changes. - -### Public Type Inventory - -Create: - -```text -src/PUBLIC_API_SURFACE_INVENTORY.md -``` - -Decide: - -- curated public API -- legacy compatibility API -- internal implementation exports -- generated surface ownership -- replacement path for every hidden or removed public type - -Acceptance: - -- every exported public type has category, user need, docs page, sample path, - and removal/deprecation milestone where relevant -- no runtime code changes - -### Provider Inventory - -Create: - -```text -src/core/di/PROVIDER_SURFACE_INVENTORY.md -``` - -Decide: - -- internal recipe providers -- registry providers -- policy providers -- config-free providers -- legacy-public providers -- preferred user path for every public provider method - -Acceptance: - -- provider inventory agrees with public type inventory -- config-free providers have no recommended direct user path -- mixed registry/policy providers are explicitly marked - -### Service Contract Inventory - -Create: - -```text -src/services/SERVICE_POLICY_INVENTORY.md -``` - -Decide: - -- which services own browser lifecycle mechanics -- which services need reactive runtime state -- which services need config hardening -- which services need diagnostics, recovery, persistence, native interop, or - adapter boundaries -- which services should remain small utilities - -Acceptance: - -- every service has applies/not-applicable decisions for `SERVICE_CONTRACTS.md` -- missing docs and missing tests are listed -- no runtime code changes - -### Configure API Shape - -Accept the built-in service config API before migrating provider policy docs. - -Candidate shape: - -```ts -app.configure("http", { - defaults: { withCredentials: true }, -}); - -app.configure({ - location: { html5Mode: true }, - log: { debug: false }, -}); -``` - -Decide: - -- keyed config, object config, or both -- config merge semantics and ordering -- module-local, app-global, or inherited behavior -- explicit handling for security-sensitive config -- type behavior for unknown keys and fields - -Acceptance: - -- accepted `AngularConfigMap` shape -- type tests for valid and invalid config -- runtime application rules -- one docs sample for a built-in service config - -### Plugin API Shape - -Accept the third-party extension API before removing provider objects from -plugin authoring examples. - -Candidate shape: - -```ts -export interface AngularPlugin { - name: string; - install(app: ng.NgModule, config: TConfig): void; -} - -app.use(firebasePlugin, { - apiKey: "...", - authDomain: "...", -}); -``` - -Decide: - -- plugin object shape -- no-config plugin ergonomics -- required-config plugin typing -- install ordering and idempotency -- whether plugins can contribute named built-in-style config keys - -Acceptance: - -- accepted `AngularPlugin` shape -- type tests for required, optional, and invalid plugin config -- fixture plugin docs sample - -## Phase 2: Vertical Module Slices - -After the global decisions exist, policy hardening and provider cleanup should -move per module. Each module slice must include docs, tests, generated surface -updates where applicable, and migration notes where public API changes. - -Each module slice follows this order: - -1. Document current behavior. -2. Classify public types and provider methods for the module. -3. Design service-owned config for the module. -4. Add or update `NgModule`, `app.configure(...)`, or `app.use(...)` user path. -5. Harden policy, reactivity, failure, recovery, or lifecycle behavior. -6. Add type tests and source tests. -7. Add docs guidance and executable or testable samples. -8. Regenerate declarations, TypeDoc, and generated integrations where relevant. -9. Mark provider paths legacy/internal only after the replacement path is - documented and tested. - -## Pilot Slices - -Start with small, representative pilots before touching broad provider -internals. - -### Pilot A: Small Policy Service - -Good candidates: - -- `$log` -- `$cookie` -- `$location` - -Purpose: - -- prove `app.configure(...)` -- prove service-owned config docs -- prove generated docs and sample requirements -- prove provider-to-config migration notes - -Exit criteria: - -- one policy provider has a documented service config replacement -- provider docs point to the new user path -- type tests reject invalid config -- docs sample exercises the intended path - -### Pilot B: Config-Free Provider - -Good candidates: - -- `$machineProvider` -- `$workflowProvider` - -Purpose: - -- prove that config-free providers can move out of the normal public surface - without reducing capability -- prove runtime service injection remains the user path -- prove namespace/docs handling for legacy/internal providers - -Exit criteria: - -- runtime service docs are complete -- provider path is classified as config-free or legacy/internal -- no user-facing guide teaches provider access -- generated surface decision is explicit - -### Pilot C: Realtime Browser Lifecycle Service - -Good candidates: - -- `$websocket` -- `$sse` -- `$webTransport` - -Purpose: - -- prove policy hardening for browser mechanics users should not own manually -- prove default reconnect/heartbeat config -- prove reactive connection state only where useful -- prove deterministic fake backend tests - -Exit criteria: - -- default policy is documented -- policy override is typed and tested -- users do not need application-owned reconnect loops in examples -- native escape hatch and cleanup behavior are explicit - -### Pilot D: Reactive Model Primitive - -Candidate: - -- `app.model(...)` - -Purpose: - -- provide a first-party answer for small shared reactive state -- prove `NgModule` can expose state primitives without provider objects -- prove scope-proxy-backed injectable services -- reduce pressure to reach for external state-store dependencies - -Exit criteria: - -- model declaration is typed and chainable -- injected value is a reactive proxy -- model instances are isolated per injector -- docs distinguish models from `$scope`, `$eventBus`, `$machine`, and - `$workflow` -- executable or testable sample shows shared state without manual pub/sub - -## Phase 3: Provider Redesign After Evidence - -Provider redesign starts only after at least one policy provider and one -config-free provider slice have proven the replacement patterns. - -Allowed work: - -- simplify internal provider recipes -- adapt config-free providers first -- keep `$get` compatibility inside DI -- keep runtime behavior compatible -- remove provider docs from normal user paths - -Not allowed yet: - -- broad injector rewrite -- flag-day provider removal -- public namespace cleanup before replacements are documented and tested -- moving every provider to one generic config object without module evidence - -Exit criteria: - -- injector tests pass -- namespace changes are intentional -- TypeDoc does not promote internal recipes as public API -- public docs teach `NgModule`, `app.configure(...)`, and `app.use(...)` - -## Phase 4: Compatibility And Cleanup - -Only after vertical slices prove replacements should AngularTS remove or hide -legacy public provider surface. - -Rules: - -- old provider paths remain runtime-compatible during the window -- new examples use replacement APIs only -- migration notes identify removal milestones -- generated surfaces follow the curated public surface decision -- unpublished integrations are intentionally excluded or updated according to - the generated surface rule - -Exit criteria: - -- users can build normal apps without provider objects -- config is documented beside runtime services -- public types map to actual user needs -- generated docs and samples describe the intended path -- provider internals remain available to the injector -- public type count shrinks without reducing capability - -## Level-9 Definition - -AngularTS reaches level 9 for the JS public surface when: - -- the app authoring model is declarative -- browser lifecycle mechanics are owned by services with sensible defaults -- users configure policies instead of writing boilerplate -- scopes/reactivity keep primitives synchronized -- provider objects are internal or advanced, not the default user path -- generated docs and exposed types are documented -- every public API has guidance and an executable or testable sample -- generated integrations track the curated public surface intentionally -- migrations exist for every public break - -Level 9 is not just more tests or fewer exports. It is the point where the -public API, docs, samples, generated types, and runtime behavior all describe -the same stable contract. diff --git a/src/NAMESPACE_SURFACE_CONTRACT.md b/src/NAMESPACE_SURFACE_CONTRACT.md new file mode 100644 index 000000000..b2051c953 --- /dev/null +++ b/src/NAMESPACE_SURFACE_CONTRACT.md @@ -0,0 +1,41 @@ +# Namespace Surface Contract + +The `ng` namespace is the documented type surface that users encounter while +authoring, configuring, extending, or consuming AngularTS applications. + +## Inclusion Rule + +A type belongs in `src/namespace.ts` when at least one public API exposes it to +users and its name improves authoring, generated bindings, or documentation. +A named primitive alias may remain when the name communicates domain meaning; +`Expression` is an example. + +Do not export: + +- provider recipes or provider-shaped aliases +- internal composition details +- protocol implementation details that users cannot encounter +- aliases that only rename another framework or Web Platform type without + adding useful semantics + +Every public injection token must map directly to a named `ng` contract in +`InjectionTokenMap`. The contract must be exported through `src/docs.ts` so it +has generated documentation. + +## Generated Surfaces + +Closure, ClojureScript, Dart, Gleam, Kotlin, Rust parity, declarations, and +TypeDoc must track `src/namespace.ts`. Unpublished integrations do not justify +leaking implementation types into the namespace. + +Do not use the `@internal` TypeDoc tag in `src/namespace.ts`; declaration emit +strips those exports. Hide internal types by not exporting them. + +## Verification + +```bash +make internal-composition-check +make test-namespace-js +make generated-check +make public-type-docs-check +``` diff --git a/src/POLICY_PRIMITIVE_CANDIDATES.md b/src/POLICY_PRIMITIVE_CANDIDATES.md new file mode 100644 index 000000000..3e4329790 --- /dev/null +++ b/src/POLICY_PRIMITIVE_CANDIDATES.md @@ -0,0 +1,229 @@ +# Policy Primitive Candidates + +This registry records policy domains that qualify for future AngularTS +implementation. It is intentionally not a backlog of automatic features. + +Policy earns a primitive only when the decision is runtime, cross-cutting, and +consistency-sensitive. Service-local behavior remains service config. + +## Qualification Checklist + +- [ ] The decision is evaluated at runtime, not only at bootstrap. +- [ ] More than one subsystem needs the same decision. +- [ ] Duplicating the rule in application code would create incoherent behavior. +- [ ] The decision can be expressed as a typed `PolicyContext` plus + `PolicyDecision`. +- [ ] Defaults are safe and do not force normal users to learn the policy. +- [ ] The policy has deterministic precedence and focused tests. +- [ ] Docs can show one executable sample without provider access. + +## Implemented Proof Points + +- [x] Security policy: request and navigation gates. +- [x] REST cache policy: cache strategy decisions per request. +- [x] Event delivery policy: `$eventBus` listener delivery decisions. +- [x] Router tree navigation policy: inherited route policy composed with + `$security`. + +## Remaining Candidates + +### Router Tree Navigation Policy + +Scope: + +- router state declarations +- `$transitions` +- `$security` +- route-level resolves, controllers, and views + +Decision shape: + +```ts +type RouterNavigationDecision = "allow" | "deny" | "redirect"; +``` + +Why it qualifies: + +- navigation policy affects the router, security, resolves, component entry, + redirects, and diagnostic behavior. +- large route trees need one inherited rule at a parent or abstract state rather + than repeated guards on every child route. +- route policy becomes incoherent if each module owns its own auth checks, + redirects, and permission gates. + +Implementation status: + +- [x] Finish route-tree policy inheritance and document the resulting router + behavior. +- [x] Define the state declaration shape for inherited navigation policy. +- [x] Decide explicit weakening semantics for public child routes. +- [x] Compose inherited state policy with `$security.navigation.rules`. +- [x] Prove lazy state registration preserves parent policy inheritance. + +### Diagnostic Redaction Policy + +Scope: + +- `$log` +- `$exceptionHandler` +- `$http` +- `$security` +- `$workflow` +- `$eventBus` + +Decision shape: + +```ts +type DiagnosticPolicyDecision = "emit" | "drop" | "redact"; +``` + +Why it qualifies: + +- logs, exceptions, request diagnostics, workflow inputs, and security reasons + can all leak sensitive data if each subsystem redacts independently. +- enterprise apps need one auditable rule for PII, tokens, credentials, request + bodies, and workflow payloads. + +Implementation stoppages: + +- [ ] Define a safe diagnostic envelope shared by participating services. +- [ ] Decide whether redaction mutates payloads, returns replacements, or both. +- [ ] Define default secret patterns and the application override boundary. +- [ ] Prove `$exceptionHandler` and `$log` behavior before wiring high-volume + services. +- [ ] Document which diagnostics remain application-owned. + +### Trusted Origin And Credential Policy + +Scope: + +- `$http` +- `$rest` +- `$websocket` +- `$sse` +- `$webTransport` +- service worker messaging and cache adapters + +Decision shape: + +```ts +type TrustedOriginDecision = "allow" | "deny"; +``` + +Why it qualifies: + +- credentials, auth headers, cookies, and tokenized URLs must follow one origin + rule across every network-capable primitive. +- route/security policy is incomplete if network services can still send + sensitive requests to disallowed targets. + +Implementation stoppages: + +- [ ] Define normalized URL/origin context for HTTP, realtime, and worker + boundaries. +- [ ] Decide whether denied WebSocket/SSE/WebTransport opens reject, no-op, or + emit stable diagnostics. +- [ ] Compose with `$security.credentials` without duplicating auth branches. +- [ ] Keep service-local reconnect/cache config separate from origin decisions. + +### Workflow Command Admission Policy + +Scope: + +- `$workflow` +- `$machine` +- workflow supervisor +- `$security` +- app models + +Decision shape: + +```ts +type CommandAdmissionDecision = "run" | "reject" | "queue"; +``` + +Why it qualifies: + +- commands can represent permissions, offline mutation, recovery flow, or user + approval boundaries. +- large apps need one rule for whether a command may run now instead of + scattering checks inside command handlers. + +Implementation stoppages: + +- [ ] Keep workflow concurrency policy distinct from command admission. +- [ ] Define the engine-neutral context so workflows can run without machines. +- [ ] Decide how rejections appear in workflow diagnostics and history. +- [ ] Prove supervisor recovery does not bypass admission decisions. + +### Storage Persistence Policy + +Scope: + +- app models +- `$workflow` supervisor snapshots +- storage service +- REST cache +- service worker cache adapters + +Decision shape: + +```ts +type PersistenceDecision = "persist" | "skip" | "expire" | "encrypt-required"; +``` + +Why it qualifies: + +- durable state has retention, sensitivity, encryption, and expiry concerns that + should not be reinvented by each persistence owner. +- app models and workflow snapshots need coherent rules when root scopes are + destroyed and recreated. + +Implementation stoppages: + +- [ ] Define persistence metadata without making every model durable. +- [ ] Decide whether encryption is framework-provided or an app-owned adapter + requirement. +- [ ] Define expiry semantics across storage backends and cache stores. +- [ ] Keep persistence policy separate from storage backend config. + +### Template And Resource Trust Policy + +Scope: + +- `$sce` +- `$templateRequest` +- `$compile` +- `$http` +- dynamic component/template loading + +Decision shape: + +```ts +type ResourceTrustDecision = "trust" | "sanitize" | "deny"; +``` + +Why it qualifies: + +- template URLs, dynamic HTML, resource URLs, and compile-time DOM insertion are + one trust boundary even when different services touch them. +- app security posture is incoherent if `$sce`, template fetching, and compile + insertion make unrelated trust decisions. + +Implementation stoppages: + +- [ ] Preserve `$sce` as the explicit trusted-value boundary. +- [ ] Define which resources can be sanitized and which can only be denied. +- [ ] Prove template request and compile insertion consume the same decision + without making normal static templates harder. +- [ ] Document native browser CSP/Trusted Types responsibilities. + +## Rejection Rule + +A candidate is rejected or deferred when: + +- it affects only one service; +- it is static configuration rather than runtime decision-making; +- it cannot be tested deterministically; +- it hides a risky application-owned choice; +- it would force small apps to learn enterprise policy concepts. diff --git a/src/PUBLIC_API_SURFACE_ROADMAP.md b/src/PUBLIC_API_SURFACE_ROADMAP.md deleted file mode 100644 index 0bf8dd1ab..000000000 --- a/src/PUBLIC_API_SURFACE_ROADMAP.md +++ /dev/null @@ -1,630 +0,0 @@ -# Public API Surface Roadmap - -This roadmap shrinks the curated public AngularTS API without removing -capability. The goal is to stop exporting implementation details, provider -plumbing, and duplicated type aliases as first-class user concepts. - -The global execution sequence is defined in -`src/GLOBAL_LEVEL_9_ROADMAP.md`. This public API roadmap should be executed -inside that sequence: global decisions first, vertical module slices second, -provider redesign after patterns are proven. - -AngularTS can remain broad while making the public surface smaller. Users -should see app declarations, runtime services, config objects, and intentional -extension points. Internal DI recipes and config-free providers should stay -inside the framework. - -Provider-specific migration work is tracked in -`src/core/di/PROVIDER_SURFACE_ROADMAP.md`. Service-by-service config and -reactivity backfill is tracked in -`src/services/SERVICE_POLICY_REAPPLICATION_ROADMAP.md`. -App-owned reactivity is tracked in -`src/core/scope/APPCONTEXT_IMPLEMENTATION_ROADMAP.md` and must lead model and -service reactive-state work. - -Documentation completeness for public API work is defined in -`src/DOCUMENTATION_REQUIREMENT.md`. Every slice that changes exposed types, -generated docs, service config, provider visibility, or user-facing behavior -must update guidance and testable samples as part of the same change. - -## Goal - -Reduce the public namespace and documentation surface by moving users from -provider objects and implementation-only types to: - -- `NgModule` declaration methods -- service-owned config types -- `app.model(...)` for shared reactive model services -- `app.configure(...)` for built-in service config -- `app.use(plugin, config)` for third-party modules -- runtime service types users actually inject or hold -- extension-point types users intentionally implement - -Capability must remain available through better entry points before old public -types are hidden or removed. - -## Non-Goals - -- Do not remove internal provider mechanics. -- Do not treat documentation as a CI-only concern; it is part of the public API - contract. -- Do not remove a type only because it is inconvenient to document. -- Do not hide an extension point that users must implement. -- Do not break the namespace accidentally; public shrinkage must be intentional - and migration-backed. -- Do not make generated integration parity track unpublished/internal types. -- Do not replace every explicit config type with an untyped object map. - -## Public Type Rule - -A type may be public only when users directly need it to do at least one of the -following: - -- author an app declaration -- inject or hold a runtime service/object -- configure framework behavior -- implement a callback, handler, backend, adapter, or extension point -- consume stable diagnostics, snapshots, events, or results - -If a type exists only because the injector, generated code, or implementation -needs it, it is internal. - -## Target Public Type Categories - -Keep public: - -- app authoring types: `NgModule`, `Component`, `Directive`, `Injectable` -- app authoring methods: `component`, `directive`, `controller`, `model`, - `configure`, `use` -- runtime services: `HttpService`, `LocationService`, `MachineService` -- runtime objects: `Machine`, `Workflow`, `RestService` -- service config types: `HttpConfig`, `LocationConfig`, `CookieConfig` -- user-implemented callbacks: `MachineTransition`, `WorkflowCommand` -- extension points: `RestBackend`, `RestCacheStore`, storage backends -- stable evidence types: snapshots, diagnostics, history entries, command - results - -Move internal or legacy: - -- config-free provider types -- registry provider types when `NgModule` methods cover the behavior -- provider aliases that duplicate runtime services -- implementation-only provider tokens -- internal helper records not authored by users -- generated bridge-only types not needed by JS/TS users - -## Implementation Stoppages - -These are intentional stoppages. When a slice reaches one of these points, -implementation must pause long enough to record the design decision in the -roadmap, inventory, docs, or migration notes before changing public behavior. - -### Stoppage 0: App-Owned Reactivity Does Not Exist - -Stop when: - -- a slice wants to add `app.model(...)`, app-level reactive service state, - service-owned diagnostics, reactive connection status, workflow supervisor - snapshots, or persistent reactive state before internal `AppContext` exists. - -Decide: - -- how app-owned reactive proxies are created -- how `$rootScope` is created by app context -- how app lifetime differs from UI scope lifetime -- how app-owned proxies are destroyed -- which downstream services are app-owned, view-owned, or not reactive - -Unlock: - -- `src/core/scope/APPCONTEXT_IMPLEMENTATION_ROADMAP.md` has an accepted - ownership contract and downstream dependency map. - -### Stoppage 1: Public Inventory Does Not Exist - -Stop when: - -- a slice wants to hide, rename, remove, or newly expose any public type before - `src/PUBLIC_API_SURFACE_INVENTORY.md` exists. - -Decide: - -- which exports are curated public API -- which exports are legacy compatibility API -- which exports are internal implementation details -- which generated surfaces should track each category - -Unlock: - -- every changed public type has an inventory row, category, user need, - replacement path, docs page, sample path, and removal/deprecation milestone. - -### Stoppage 2: Provider Inventory Does Not Exist - -Stop when: - -- a slice wants to hide provider docs, remove provider namespace exports, add - `app.configure(...)`, or claim a provider is config-free before - `src/core/di/PROVIDER_SURFACE_INVENTORY.md` exists. - -Decide: - -- provider category: internal recipe, registry, policy, config-free, or - legacy-public -- preferred user path for each provider method -- whether mixed registry/policy providers must be split before migration - -Unlock: - -- provider inventory agrees with the public type inventory and identifies - every replacement path. - -### Stoppage 3: Service Contract Inventory Does Not Exist - -Stop when: - -- a slice wants to add service config, reactive state, diagnostics, recovery, - persistence, or native adapter APIs before - `src/services/SERVICE_POLICY_INVENTORY.md` exists. - -Decide: - -- which `SERVICE_CONTRACTS.md` sections apply to each service -- which services own browser lifecycle mechanics -- which services should remain small utilities -- which runtime state should become reactive - -Unlock: - -- each service has explicit applies/not-applicable decisions and missing docs - or tests are listed. - -### Stoppage 4: `app.configure(...)` Shape Is Still Candidate - -Stop when: - -- a slice wants to implement service-owned config or migrate provider policy - docs to `app.configure(...)`. - -Decide: - -- overload shape: keyed config, object config, or both -- config merge semantics and ordering -- whether config is module-local, app-global, or inherited -- how security-sensitive config stays explicit -- how unknown config keys and unknown fields fail type checks - -Unlock: - -- accepted `AngularConfigMap` shape, type tests, runtime application rules, and - at least one docs sample for a built-in service config. - -### Stoppage 5: `app.use(plugin, config)` Shape Is Still Candidate - -Stop when: - -- a slice wants to document third-party extension authoring or remove provider - objects from plugin examples. - -Decide: - -- plugin object shape -- no-config plugin ergonomics -- required-config plugin typing -- install ordering and idempotency semantics -- whether plugins can contribute named config keys or must configure only - through their own `install()` call - -Unlock: - -- accepted `AngularPlugin` shape, type tests for valid/invalid - plugin config, and one fixture plugin sample. - -### Stoppage 6: Public Docs Still Teach Provider Plumbing - -Stop when: - -- a slice wants to mark a provider legacy/internal while docs still present it - as the normal user path. - -Decide: - -- where the user-facing guide lives under `docs/content` -- whether the replacement is `NgModule`, `app.configure(...)`, `app.use(...)`, - direct service injection, or an advanced extension page -- what migration note is required - -Unlock: - -- docs teach the replacement path first, provider docs are marked - legacy/internal/advanced, and examples use the replacement path. - -### Stoppage 7: Generated Surface Ownership Is Ambiguous - -Stop when: - -- a slice changes `ng` namespace exports, generated externs, generated - integration bindings, or TypeDoc visibility without a documented surface - ownership decision. - -Decide: - -- whether generated integrations track curated public API, compatibility API, - or internal implementation exports -- which unpublished integrations are intentionally excluded -- whether TypeDoc should show a symbol as public, legacy, advanced, or hidden - -Unlock: - -- generated surface rules are documented and generated output changes are - intentional. - -### Stoppage 8: Documentation Requirement Is Not Satisfied - -Stop when: - -- a public API slice has code or type changes without matching generated docs, - user guidance, samples, and migration notes. - -Decide: - -- the docs page for the intended user path -- the generated docs impact -- the sample path under `docs/content` or `docs/static/examples` -- the source tests that prove behavior beyond documentation snippets - -Unlock: - -- `src/DOCUMENTATION_REQUIREMENT.md` is satisfied for the changed API. - -### Stoppage 9: Compatibility Window Is Undefined - -Stop when: - -- a slice wants to deprecate, hide, or remove a public provider/type without a - compatibility window. - -Decide: - -- whether the old API remains runtime-compatible -- where deprecation is documented -- removal milestone or major version -- replacement tests that must exist before deprecation - -Unlock: - -- migration notes and replacement tests exist before removal or hard hiding. - -### Stoppage 10: Internal Provider Recipe Refactor Leaks Publicly - -Stop when: - -- a slice changes provider construction internals and namespace, TypeDoc, or - docs output changes unexpectedly. - -Decide: - -- whether the change is internal-only or public API -- whether provider recipes need a compatibility adapter -- whether `$get` compatibility remains visible only inside DI - -Unlock: - -- injector tests pass, namespace output is unchanged unless intentionally - changed, and no internal recipe type is taught as public API. - -## Slice 1: Public Type Inventory - -Create an inventory of every type exported through `ng` and package entry -points. - -Candidate location: - -```text -src/PUBLIC_API_SURFACE_INVENTORY.md -``` - -Inventory columns: - -- type name -- source file -- current public entry point -- category: authoring, runtime, config, callback, extension, evidence, - provider, internal, legacy -- user need: author/inject/configure/implement/consume/none -- replacement path, if removed -- docs page -- tests covering public use -- keep public: yes/no/defer -- removal version or milestone - -Acceptance: - -- Every `export type` in `src/namespace.ts` is listed. -- Every top-level package type export is listed. -- Every provider type is categorized. -- Every `keep public: no` entry has a replacement path or explicit rationale. -- No runtime code changes. - -Verification: - -```bash -rg -n "export type|export interface|export class" src/namespace.ts src/index.ts -rg -n "\\| .* \\|" src/PUBLIC_API_SURFACE_INVENTORY.md -``` - -## Slice 2: Provider Type Classification - -Use `src/core/di/PROVIDER_SURFACE_ROADMAP.md` to classify provider types. - -Rules: - -- Config-free providers should become internal or legacy-public. -- Registry providers should become advanced/internal once `NgModule` - replacements exist. -- Policy providers stay public until service-owned config types and - `app.configure(...)` cover their documented behavior. -- Mixed registry/policy providers must be split conceptually before removal. - -Acceptance: - -- Provider type inventory references provider category. -- Provider roadmap and public type inventory agree. -- Config-free providers have no recommended direct user path. - -Verification: - -```bash -rg -n "config-free|registry|policy|legacy-public" \ - src/core/di/PROVIDER_SURFACE_ROADMAP.md \ - src/PUBLIC_API_SURFACE_INVENTORY.md -``` - -## Slice 3: Service-Owned Config Types - -Move configuration concepts beside the service they configure. - -Rules: - -- Prefer `HttpConfig`, `LocationConfig`, `LogConfig`, `CookieConfig`, etc. -- Config docs belong on service pages, not provider pages. -- Provider defaults may remain implementation details until migration. -- Config types must be strict and type-safe. - -Acceptance: - -- Draft config types for all policy providers. -- Each config type maps to current provider behavior. -- Service docs include config sections. -- Provider docs point to service config docs or are marked legacy/internal. - -Verification: - -```bash -rg -n "interface .*Config|type .*Config" src/services src/core -rg -n "## Configure|app.configure" docs/content/docs/service docs/content/docs/services -``` - -## Slice 4: Typed Configure API Design - -Design the type-safe built-in config API. - -Candidate shape: - -```ts -export interface AngularConfigMap { - http: HttpConfig; - location: LocationConfig; - log: LogConfig; -} - -app.configure("http", { - defaults: { withCredentials: true }, -}); - -app.configure({ - location: { html5Mode: true }, - log: { debug: false }, -}); -``` - -Rules: - -- Built-in config keys are typed. -- Invalid keys and invalid fields fail type checks. -- Config application happens during module configuration. -- Security-sensitive config stays explicit. -- Third-party modules should prefer `app.use(plugin, config)` unless a - registry extension is intentionally provided. - -Acceptance: - -- Add type tests for key-specific config. -- Add type tests for object config. -- Add type tests rejecting unknown config keys and fields. -- No provider removal in this slice. - -Verification: - -```bash -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 5: Typed Plugin API Design - -Design a type-safe extension API for third-party modules. - -Candidate shape: - -```ts -export interface AngularPlugin { - name: string; - install(app: ng.NgModule, config: TConfig): void; -} - -app.use(firebasePlugin, { - apiKey: "...", - authDomain: "...", -}); -``` - -Rules: - -- Plugin objects carry their config type. -- Users should not need to augment global maps for ordinary plugins. -- Plugins can internally register constants, factories, services, directives, - config blocks, or policies. -- Plugin public API should hide provider internals. - -Acceptance: - -- Add `NgModule.use()` type tests. -- Add a fixture plugin with required config. -- Add a fixture plugin with no config. -- Add tests rejecting invalid plugin config. - -Verification: - -```bash -./node_modules/.bin/tsc --project tsconfig.test.json -npx playwright test src/core/di/ng-module/ng-module.test.ts -``` - -## Slice 6: Documentation Surface Reduction - -Move docs from provider pages to service pages where users think about the -feature. - -Rules: - -- Service pages own runtime API and config docs. -- Provider pages are legacy/internal/advanced unless still required. -- Tutorials should not inject providers for normal app authoring. -- Examples should use `NgModule`, `configure`, or `use`. - -Acceptance: - -- `$httpProvider` config docs move under `$http`. -- `$locationProvider` config docs move under `$location`. -- `$logProvider` config docs move under `$log`. -- Registry provider docs point to `NgModule` methods. -- Config-free provider docs are hidden or marked internal. - -Verification: - -```bash -rg -n "\\$.*Provider" docs/content/docs/get-started docs/content/docs/service docs/content/docs/services -make docs-examples-check -``` - -## Slice 7: Curated Namespace Split - -Separate curated public namespace from internal/legacy implementation exports. - -Rules: - -- `ng` should contain user-authoring, runtime, config, callback, extension, and - evidence types. -- Internal provider recipe types should not be in the curated namespace. -- Legacy provider types can remain under a clearly marked compatibility surface - until the next major cleanup. -- Generated parity should target the curated public surface, not every internal - exported implementation type. - -Acceptance: - -- Define curated namespace inclusion rules in code comments or docs. -- Namespace tests distinguish public, legacy, and internal surfaces. -- TypeDoc public nav follows the curated surface. -- Generated externs/bindings are updated to the intended public surface. - -Verification: - -```bash -make test-namespace-js -make generated-check -``` - -## Slice 8: Compatibility Window - -Keep old provider paths working while new public paths are adopted. - -Rules: - -- Runtime behavior remains compatible during the window. -- Provider docs warn users toward replacements. -- New examples use replacement APIs only. -- Deprecation notes identify removal milestone. - -Acceptance: - -- Migration notes exist for every legacy public type. -- Existing provider-based tests still pass. -- Replacement tests are added before deprecation. - -Verification: - -```bash -make check -``` - -## Slice 9: Major Public Surface Cleanup - -Remove or hide legacy public types after replacements are stable. - -Candidate removals: - -- config-free provider types from `ng` -- internal registry provider types from normal docs -- provider aliases duplicating runtime services -- implementation-only tokens from public docs - -Rules: - -- This is a major-version operation. -- Namespace snapshot changes must be intentional. -- Migration guide maps every removed public symbol to replacement or rationale. -- Internal DI behavior remains intact. - -Acceptance: - -- Public type count decreases. -- Provider docs count decreases. -- Generated public surfaces decrease. -- All common app examples still work without provider objects. - -Verification: - -```bash -make check -make generated-check -make docs-examples-check -``` - -## Metrics - -Track before and after: - -- number of public `ng` namespace types -- number of public provider types -- number of provider docs pages -- number of docs examples that inject `*Provider` -- number of generated extern/binding provider entries -- number of common app flows requiring provider knowledge - -The target is not a tiny framework. The target is a smaller curated public -surface with the same or greater application capability. - -## Final Readiness Gate - -Public API surface cleanup is ready when: - -- the level-9 documentation requirement in `src/DOCUMENTATION_REQUIREMENT.md` - is satisfied for every changed public API -- users can build normal apps without seeing provider objects -- config is documented beside runtime services -- public types map to actual user authoring needs -- provider internals remain available to the injector -- generated parity tracks the curated public surface -- migration notes exist for every intentional break -- public type count shrinks without removing app capability diff --git a/src/angular-runtime.spec.ts b/src/angular-runtime.spec.ts new file mode 100644 index 000000000..6a4da981d --- /dev/null +++ b/src/angular-runtime.spec.ts @@ -0,0 +1,301 @@ +/// +import { Angular } from "./angular.ts"; +import { + _animate, + _compile, + _controller, + _eventBus, + _exceptionHandler, + _filter, + _interpolate, + _parse, + _rootScope, + _state, + _templateCache, + _templateRequest, + _worker, +} from "./injection-tokens.ts"; +import type { StateRuntime } from "./router/state/state-service.ts"; +import type { + NgViewAnimData, + ViewConfig, + ViewService, +} from "./router/view/view.ts"; +import { wait } from "./shared/test-utils.ts"; + +describe("AngularRuntime composition ownership", () => { + const runtimes: Angular[] = []; + const angularHost = window as unknown as { angular?: ng.Angular }; + let originalAngular: ng.Angular | undefined; + let originalWorker: typeof Worker; + + beforeEach(() => { + originalAngular = angularHost.angular; + originalWorker = window.Worker; + }); + + afterEach(() => { + runtimes.forEach((runtime) => { + runtime._composition.destroy(); + }); + runtimes.length = 0; + + if (originalAngular) { + angularHost.angular = originalAngular; + } else { + delete angularHost.angular; + } + + window.Worker = originalWorker; + }); + + function createRuntime(): { + runtime: Angular; + injector: ng.InjectorService; + } { + const runtime = new Angular(); + + runtimes.push(runtime); + + return { + runtime, + injector: runtime.injector(["ng"]), + }; + } + + it("gives sub-applications non-owning compositions", () => { + const runtime = new Angular(); + const subapp = new Angular(true); + + runtimes.push(runtime, subapp); + + expect(subapp._appContext).toBe(runtime._appContext); + + subapp._composition.destroy(); + + expect(subapp._composition.destroyed).toBeTrue(); + expect(runtime._composition.destroyed).toBeFalse(); + expect(runtime._appContext.destroyed).toBeFalse(); + + runtime._composition.destroy(); + + expect(runtime._composition.destroyed).toBeTrue(); + expect(runtime._appContext.destroyed).toBeTrue(); + }); + + it("isolates mutable framework services between top-level runtimes", () => { + const first = createRuntime(); + const firstEventBus = first.injector.get(_eventBus); + const firstCompileLifecycle = first.runtime._composition.compileLifecycle; + const firstTemplateCache = first.injector.get(_templateCache); + const repeatedFirstInjector = first.runtime.injector(["ng"]); + const second = createRuntime(); + const secondEventBus = second.injector.get(_eventBus); + const secondCompileLifecycle = second.runtime._composition.compileLifecycle; + const secondTemplateCache = second.injector.get(_templateCache); + const listener = jasmine.createSpy("firstRuntimeListener"); + + firstEventBus.subscribe("runtime:event", listener); + firstTemplateCache.set("runtime-owner", "first"); + + expect(first.runtime._appContext).not.toBe(second.runtime._appContext); + expect(first.runtime._appContext.modelScheduler).not.toBe( + second.runtime._appContext.modelScheduler, + ); + expect(repeatedFirstInjector.get(_eventBus)).toBe(firstEventBus); + expect(first.runtime._composition.compileLifecycle).toBe( + firstCompileLifecycle, + ); + expect(firstEventBus).not.toBe(secondEventBus); + expect(firstCompileLifecycle).not.toBe(secondCompileLifecycle); + expect(firstTemplateCache).not.toBe(secondTemplateCache); + expect(firstEventBus.getCount("runtime:event")).toBe(1); + expect(secondEventBus.getCount("runtime:event")).toBe(0); + expect(firstTemplateCache.get("runtime-owner")).toBe("first"); + expect(secondTemplateCache.has("runtime-owner")).toBeFalse(); + + const runtimeOwnedCoreTokens = [ + _animate, + _compile, + _controller, + _exceptionHandler, + _filter, + _interpolate, + _parse, + _rootScope, + _templateRequest, + ] as const; + + runtimeOwnedCoreTokens.forEach((token) => { + expect(repeatedFirstInjector.get(token)) + .withContext(`${token} should be stable within its runtime`) + .toBe(first.injector.get(token)); + expect(first.injector.get(token)) + .withContext(`${token} should be isolated between runtimes`) + .not.toBe(second.injector.get(token)); + }); + + first.runtime._appContext.destroy(); + + expect(firstEventBus.isDisposed()).toBeTrue(); + expect(firstEventBus.getCount("runtime:event")).toBe(0); + expect(secondEventBus.isDisposed()).toBeFalse(); + expect(second.runtime._appContext.destroyed).toBeFalse(); + }); + + it("keeps composed services lazy at the public injector boundary", () => { + const runtime = new Angular(); + let constructions = 0; + + runtimes.push(runtime); + + runtime + .module("lazyCompositionProbe", ["ng"]) + .decorator(_animate, ($delegate: ng.AnimateService) => { + constructions++; + + return $delegate; + }); + + const injector = runtime.injector(["lazyCompositionProbe"]); + + expect(constructions).toBe(0); + + const service = injector.get(_animate); + + expect(constructions).toBe(1); + expect(injector.get(_animate)).toBe(service); + expect(constructions).toBe(1); + }); + + it("keeps scope runtime dependencies isolated between top-level runtimes", async () => { + const firstErrors: unknown[] = []; + const secondErrors: unknown[] = []; + const firstRuntime = new Angular(); + const secondRuntime = new Angular(); + + runtimes.push(firstRuntime, secondRuntime); + + firstRuntime + .module("firstRuntime", ["ng"]) + .filter( + "runtimeOwner", + () => (value: unknown) => `first:${String(value)}`, + ) + .decorator("$exceptionHandler", () => (exception: unknown) => { + firstErrors.push(exception); + }); + secondRuntime + .module("secondRuntime", ["ng"]) + .filter( + "runtimeOwner", + () => (value: unknown) => `second:${String(value)}`, + ) + .decorator("$exceptionHandler", () => (exception: unknown) => { + secondErrors.push(exception); + }); + + const firstModel = firstRuntime._appContext.createReactive({ + value: "model", + }); + + const firstRoot = firstRuntime.injector(["firstRuntime"]).get(_rootScope); + const secondRoot = secondRuntime + .injector(["secondRuntime"]) + .get(_rootScope); + const observed: unknown[] = []; + const modelObserved: unknown[] = []; + const expectedError = new Error("first runtime failure"); + + firstRoot.value = "value"; + firstRoot.$watch("value | runtimeOwner", (value) => { + observed.push(value); + }); + firstModel.$watch("value | runtimeOwner", (value) => { + modelObserved.push(value); + }); + firstRoot.$on("runtime:error", () => { + throw expectedError; + }); + + firstRoot.$emit("runtime:error"); + await wait(); + + expect(observed).toEqual(["first:value"]); + expect(modelObserved).toEqual(["first:model"]); + expect(firstErrors).toEqual([expectedError]); + expect(secondErrors).toEqual([]); + expect(secondRoot.$handler._parse).not.toBe(firstRoot.$handler._parse); + }); + + it("releases root and app-owned resources during teardown", () => { + const terminate = jasmine.createSpy("terminate"); + + class TestWorker { + onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = + null; + onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null; + + postMessage(): void { + /* empty */ + } + + terminate(): void { + terminate(); + } + } + + window.Worker = TestWorker as unknown as typeof Worker; + + const runtime = new Angular(); + const rootElement = document.createElement("main"); + + runtimes.push(runtime); + document.body.append(rootElement); + + const injector = runtime.bootstrap(rootElement); + const eventBus = injector.get(_eventBus); + const worker = injector.get(_worker)("/worker.js"); + const rootScope = injector.get(_rootScope); + const root = runtime._appContext.getRootByScope(rootScope); + const view = (injector.get(_state) as StateRuntime)._viewService; + const retainedScope = rootScope.$new(); + const retainedElement = document.createElement("section"); + const scheduled = jasmine.createSpy("scheduled"); + + document.body.append(retainedElement); + eventBus.subscribe("runtime:event", () => undefined); + root?.scheduler.schedule(scheduled); + view._retainView({ + _key: "retained:test", + _config: { + _retention: { + _key: "retained:test", + _mode: "keep-alive", + _state: "test", + }, + _targetKey: "$default", + } as ViewConfig, + _element: retainedElement, + _nodes: [], + _scope: retainedScope, + _animation: {} as NgViewAnimData, + }); + + runtime._appContext.destroy(); + worker.terminate(); + root?.scheduler.flush(); + + expect(root?.destroyed).toBeTrue(); + expect(root?.scheduler.destroyed).toBeTrue(); + expect(scheduled).not.toHaveBeenCalled(); + expect(eventBus.isDisposed()).toBeTrue(); + expect(eventBus.getCount("runtime:event")).toBe(0); + expect(terminate).toHaveBeenCalledTimes(1); + expect(view._retainedViews.size).toBe(0); + expect(retainedScope.$handler._destroyed).toBeTrue(); + expect(retainedElement.isConnected).toBeFalse(); + + rootElement.remove(); + }); +}); diff --git a/src/angular-runtime.ts b/src/angular-runtime.ts index 7c208d26b..2545358bc 100644 --- a/src/angular-runtime.ts +++ b/src/angular-runtime.ts @@ -28,14 +28,24 @@ import { hasNormalizedAttr, setCacheData, } from "./shared/dom.ts"; -import type { AngularBootstrapConfig, InvocationDetail } from "./interface.ts"; +import type { + AngularBootstrapConfig, + InvocationDetail, + PublicInjectionTokens, +} from "./interface.ts"; import { createInjector, type ModuleLike } from "./core/di/injector.ts"; import { NgModule, type ModuleConfigFn, + type RouterModule, } from "./core/di/ng-module/ng-module.ts"; +import type { RouteMap } from "./router/state/interface.ts"; import { validateIsString } from "./shared/validate.ts"; -import type { ParseService } from "./core/parse/parse.ts"; +import { AppContext } from "./core/app-context/app-context.ts"; +import { + createCoreRuntime, + type RuntimeComposition, +} from "./core/composition/runtime-composition.ts"; const ngError = createErrorFactory("ng"); @@ -45,15 +55,20 @@ const rootScopeCleanupByElement = new WeakMap void>(); type ModuleRegistry = Record; -const moduleRegistry: ModuleRegistry = {}; - /** @internal */ interface AppElement { _element: HTMLElement; _module: string | null; } -type AngularWindow = Window & { angular?: AngularRuntime }; +type AngularWindow = { angular?: AngularRuntime }; +type AngularRuntimeHost = { + angular?: { + _appContext?: AppContext; + _composition?: RuntimeComposition; + _moduleRegistry?: ModuleRegistry; + }; +}; export interface AngularRuntimeOptions { /** @@ -70,9 +85,10 @@ export interface AngularRuntimeOptions { export type AngularRuntimeConstructorInput = boolean | AngularRuntimeOptions; -export type BuiltinNgModuleRegistrar = (angular: ng.Angular) => ng.NgModule; +/** A framework module that can be installed into an AngularTS runtime. */ +export type RuntimeModule = (angular: AngularRuntime) => NgModule; -let builtinNgModuleRegistrar: BuiltinNgModuleRegistrar | undefined; +let builtinNgModuleRegistrar: RuntimeModule | undefined; let runtimeInjectionTokens: Readonly> | undefined; @@ -82,9 +98,7 @@ let runtimeInjectionTokens: Readonly> | undefined; * The browser entrypoint installs the full registrar. Custom runtime entrypoints * intentionally skip this so they can assemble smaller builds. */ -export function configureBuiltinRuntime( - registrar: BuiltinNgModuleRegistrar, -): void { +export function configureBuiltinRuntime(registrar: RuntimeModule): void { builtinNgModuleRegistrar = registrar; } @@ -107,9 +121,16 @@ export class AngularRuntime extends EventTarget { public _subapp: boolean; /** @internal */ public _bootsrappedModules: ModuleLike[] = []; + /** @internal */ + public _appContext: AppContext; + /** @internal */ + public _composition: RuntimeComposition; + /** @internal */ + public _moduleRegistry: ModuleRegistry; + private _injectorCreated = false; /** Application-wide event bus, available after bootstrap providers are created. */ - public $eventBus!: ng.PubSubService; + public $eventBus!: ng.EventBusService; /** Application injector, available after `bootstrap()` or `injector()` completes. */ public $injector!: ng.InjectorService; /** Root scope for the bootstrapped application. */ @@ -131,18 +152,30 @@ export class AngularRuntime extends EventTarget { /** Global framework error-handling configuration. */ public errorHandlingConfig = errorHandlingConfig; /** Public injection token names keyed by token value. */ - public $t: ng.InjectionTokens = {} as ng.InjectionTokens; + public $t: typeof PublicInjectionTokens = {} as typeof PublicInjectionTokens; /** * Creates the Angular runtime singleton or a sub-application instance. * - * @param subapp when `true`, skips assigning the instance to `window.angular` + * @param options runtime construction options. Passing `true` creates a + * sub-application and skips assigning the instance to `window.angular`. */ constructor(options: AngularRuntimeConstructorInput = false) { super(); const runtimeOptions = normalizeRuntimeOptions(options); this._subapp = runtimeOptions.subapp; + const hostRuntime = runtimeOptions.subapp + ? (window as AngularRuntimeHost).angular + : undefined; + this._composition = createCoreRuntime({ + appContext: + hostRuntime?._composition?.appContext ?? hostRuntime?._appContext, + document, + window, + }); + this._appContext = this._composition.appContext; + this._moduleRegistry = hostRuntime?._moduleRegistry ?? {}; if (runtimeInjectionTokens) { values(runtimeInjectionTokens).forEach((token) => { @@ -151,10 +184,10 @@ export class AngularRuntime extends EventTarget { } if (!runtimeOptions.subapp) { - (window as AngularWindow).angular = this; + (window as unknown as AngularWindow).angular = this; } - if (runtimeOptions.registerBuiltins) { + if (runtimeOptions.registerBuiltins && !hostRuntime?._moduleRegistry) { this.registerNgModule(); } } @@ -194,11 +227,12 @@ export class AngularRuntime extends EventTarget { * // register a new service * myModule.value('appName', 'MyCoolApp'); * - * // configure existing services inside initialization blocks. - * myModule.config(['$locationProvider', function($locationProvider) { - * // Configure existing providers - * $locationProvider.hashPrefix('!'); - * }]); + * // configure built-in services with typed object config. + * myModule.config({ + * location: { + * hashPrefix: '!', + * }, + * }); * ``` * * Then you can create an injector and load your modules like this: @@ -221,14 +255,24 @@ export class AngularRuntime extends EventTarget { name: string, requires?: string[], configFn?: ModuleConfigFn, - ): NgModule { + ): NgModule; + module( + name: string, + requires?: string[], + configFn?: ModuleConfigFn, + ): RouterModule; + module( + name: string, + requires?: string[], + configFn?: ModuleConfigFn, + ): NgModule | RouterModule { assertNotHasOwnProperty(name, "module"); - if (requires && hasOwn(moduleRegistry, name)) { - moduleRegistry[name] = null; + if (requires && hasOwn(this._moduleRegistry, name)) { + this._moduleRegistry[name] = null; } - return ensure(moduleRegistry, name, () => { + return ensure(this._moduleRegistry, name, () => { if (!requires) { throw $injectorError( "nomod", @@ -237,7 +281,17 @@ export class AngularRuntime extends EventTarget { ); } - return new NgModule(name, requires, configFn); + return new NgModule( + name, + requires, + configFn, + this._composition.animationRegistry, + this._composition.controllerRegistry, + this._composition.filterRegistry, + this._composition.compileRegistry, + this._composition.appContext, + this._composition.configRegistry, + ); }); } @@ -250,7 +304,7 @@ export class AngularRuntime extends EventTarget { dispatchEvent(event: Event): boolean { const customEvent = event as CustomEvent; - const $parse = this.$injector.get(_parse) as ParseService; + const $parse = this.$injector.get(_parse); const injectable = customEvent.type; @@ -390,16 +444,16 @@ export class AngularRuntime extends EventTarget { this._bootsrappedModules = modules; } - this._bootsrappedModules.unshift([ - "$provide", - ($provide: ng.ProvideService) => { - $provide.value(_rootElement, element); - }, - ]); - this._bootsrappedModules.unshift("ng"); - const injector = createInjector(this._bootsrappedModules, config.strictDi); + const injector = createInjector( + this._bootsrappedModules, + config.strictDi, + (registry) => { + registry.value(_rootElement, element); + }, + (name) => this.module(name), + ); injector.invoke([ _rootScope, @@ -412,10 +466,18 @@ export class AngularRuntime extends EventTarget { compile: ng.CompileService, $injector: ng.InjectorService, ) => { + const appContext = this._composition.appContext; + + this._appContext = appContext; this.$rootScope = scope; this.$injector = $injector; + this._injectorCreated = true; const rootElement = el as Element; + appContext.attachRoot(scope, { + injector: $injector, + rootElement, + }); rootScopeCleanupByElement.set(rootElement, () => { const existingScope = getInheritedData(rootElement, _scope) as @@ -472,7 +534,16 @@ export class AngularRuntime extends EventTarget { * @returns The created injector. */ injector(modules: ModuleLike[], strictDi?: boolean): ng.InjectorService { - this.$injector = createInjector(modules, strictDi); + if (this._injectorCreated) { + this.$injector.loadNewModules(modules); + + return this.$injector; + } + + this.$injector = createInjector(modules, strictDi, undefined, (name) => + this.module(name), + ); + this._injectorCreated = true; return this.$injector; } @@ -547,7 +618,7 @@ export class AngularRuntime extends EventTarget { getScopeByName(name: string): ng.Scope | undefined { validateIsString(name, "name"); - const $rootScope = this.$injector.get(_rootScope) as ng.RootScopeService; + const $rootScope = this.$injector.get(_rootScope); const scope = $rootScope.$searchByName(name); diff --git a/src/angular.spec.ts b/src/angular.spec.ts index 0a1f1d0da..464ebca1d 100644 --- a/src/angular.spec.ts +++ b/src/angular.spec.ts @@ -171,6 +171,14 @@ describe("angular", () => { expect(window.angular.$injector).not.toBe( window.angular.subapps[0].$injector, ); + expect(window.angular.subapps[0]._appContext).toBe( + window.angular._appContext, + ); + expect(window.angular._appContext.roots.length).toBeGreaterThanOrEqual(2); + const roots = window.angular._appContext.roots; + expect(roots[roots.length - 2].rootScope).not.toBe( + roots[roots.length - 1].rootScope, + ); }); it("they should share save $eventBus", () => { @@ -187,33 +195,75 @@ describe("angular", () => { }); }); + describe("AppContext root ownership", () => { + it("should attach bootstrap root metadata and remove it on destroy", () => { + const runtime = new Angular(); + const root = createElementFromHTML("
"); + const beforeCount = runtime._appContext.roots.length; + + const injector = runtime.bootstrap(root, []); + const $rootScope = injector.get("$rootScope"); + const record = runtime._appContext.getRootByElement(root); + + expect(record).toBeDefined(); + expect(record.rootScope).toBe($rootScope); + expect(record.scheduler).toBeDefined(); + expect(record.injector).toBe(injector); + expect(record.rootElement).toBe(root); + expect(runtime._appContext.roots.length).toBe(beforeCount + 1); + + $rootScope.$destroy(); + + expect(runtime._appContext.getRootByElement(root)).toBeUndefined(); + expect(runtime._appContext.getRootByScope($rootScope)).toBeUndefined(); + expect(runtime._appContext.roots.length).toBe(beforeCount); + + dealoc(root); + }); + + it("should register standalone injector roots without DOM metadata", () => { + const runtime = new Angular(); + const beforeCount = runtime._appContext.roots.length; + const injector = runtime.injector(["ng"]); + const $rootScope = injector.get("$rootScope"); + const record = runtime._appContext.getRootByScope($rootScope); + + expect(record).toBeDefined(); + expect(record.rootScope).toBe($rootScope); + expect(record.rootElement).toBeUndefined(); + expect(runtime._appContext.roots.length).toBe(beforeCount + 1); + + $rootScope.$destroy(); + + expect(runtime._appContext.getRootByScope($rootScope)).toBeUndefined(); + expect(runtime._appContext.roots.length).toBe(beforeCount); + }); + }); + describe("AngularTS service", () => { it("should override services", () => { - injector = createInjector([ - function ($provide) { - $provide.value("fake", "old"); - $provide.value("fake", "new"); - }, - ]); + angular + .module("serviceOverride", []) + .value("fake", "old") + .value("fake", "new"); + injector = createInjector(["serviceOverride"]); expect(injector.get("fake")).toEqual("new"); }); it("should inject dependencies specified by $inject and ignore function argument name", () => { - expect( - angular - .injector([ - function ($provide) { - $provide.factory("svc1", () => "svc1"); - $provide.factory("svc2", [ - "svc1", - function (s) { - return `svc2-${s}`; - }, - ]); - }, - ]) - .get("svc2"), - ).toEqual("svc2-svc1"); + angular + .module("annotatedServices", []) + .factory("svc1", () => "svc1") + .factory("svc2", [ + "svc1", + function (s) { + return `svc2-${s}`; + }, + ]); + + expect(angular.injector(["annotatedServices"]).get("svc2")).toEqual( + "svc2-svc1", + ); }); }); @@ -503,7 +553,7 @@ describe("module loader", () => { const run = () => {}; - otherModule.config(init); + otherModule._config(init); const myModule = angular.module("my", ["other"], config); @@ -520,34 +570,42 @@ describe("module loader", () => { .directive("d", "dd") .component("c", "cc") .controller("ctrl", "ccc") - .config(init2) + ._config(init2) .constant("abc", 123) .run(run), ).toBe(myModule); expect(myModule._requires).toEqual(["other"]); expect(myModule._invokeQueue).toEqual([ - ["$provide", "constant", jasmine.objectContaining(["abc", 123])], - ["$provide", "provider", jasmine.objectContaining(["sk", "sv"])], - ["$provide", "factory", jasmine.objectContaining(["fk", "fv"])], - ["$provide", "service", jasmine.objectContaining(["a", "aa"])], - ["$provide", "value", jasmine.objectContaining(["k", "v"])], + jasmine.objectContaining({ kind: "provider-registration" }), + jasmine.objectContaining({ kind: "provider-registration" }), + jasmine.objectContaining({ kind: "provider-registration" }), + jasmine.objectContaining({ kind: "provider-registration" }), + jasmine.objectContaining({ kind: "provider-registration" }), [ - "$filterProvider", + myModule._filterRegistry, "register", jasmine.objectContaining(["f", filterFn]), ], - ["$compileProvider", "directive", jasmine.objectContaining(["d", "dd"])], - ["$compileProvider", "component", jasmine.objectContaining(["c", "cc"])], [ - "$controllerProvider", + myModule._compileRegistry, + "directive", + jasmine.objectContaining(["d", "dd"]), + ], + [ + myModule._compileRegistry, + "component", + jasmine.objectContaining(["c", "cc"]), + ], + [ + myModule._controllerRegistry, "register", jasmine.objectContaining(["ctrl", "ccc"]), ], ]); expect(myModule._configBlocks).toEqual([ ["$injector", "invoke", jasmine.objectContaining([config])], - ["$provide", "decorator", jasmine.objectContaining(["dk", "dv"])], + jasmine.objectContaining({ kind: "provider-registration" }), ["$injector", "invoke", jasmine.objectContaining([init2])], ]); expect(myModule._runBlocks).toEqual([run]); @@ -575,12 +633,10 @@ describe("module loader", () => { return $delegate; }) - .config(($provide) => { - $provide.decorator("theProvider", ($delegate) => { - $delegate.api += "-second"; + .decorator("theProvider", ($delegate) => { + $delegate.api += "-second"; - return $delegate; - }); + return $delegate; }) .decorator("theProvider", ($delegate) => { $delegate.api += "-third"; diff --git a/src/angular.ts b/src/angular.ts index 6d4c4bc51..caece7c05 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -5,6 +5,7 @@ import { } from "./angular-runtime.ts"; import { $injectTokens } from "./injection-tokens.ts"; import { registerNgModule } from "./ng.ts"; +import { ScopeElement } from "./services/web-component/web-component.ts"; configureBuiltinRuntime(registerNgModule); configureRuntimeInjectionTokens($injectTokens); @@ -13,11 +14,14 @@ configureRuntimeInjectionTokens($injectTokens); * Main AngularTS runtime entry point with the full built-in `ng` module * configured by default. */ -export class Angular extends AngularRuntime {} +export class Angular extends AngularRuntime { + /** Base class for user-authored AngularTS custom elements. */ + public readonly ScopeElement = ScopeElement; +} export { configureBuiltinRuntime, configureRuntimeInjectionTokens }; export type { AngularRuntimeConstructorInput, AngularRuntimeOptions, - BuiltinNgModuleRegistrar, + RuntimeModule, } from "./angular-runtime.ts"; diff --git a/src/animations/README.md b/src/animations/README.md new file mode 100644 index 000000000..5d54a2c2c --- /dev/null +++ b/src/animations/README.md @@ -0,0 +1,68 @@ +# Animation Service + +`$animate` coordinates DOM mutation, JavaScript animation presets, CSS custom +property animations, native Web Animations, and document view transitions. + +## Public Surface + +- `AnimateService`: injected runtime animation operations. +- `AnimationHandle`: cancellable, pausable, promise-like animation result. +- `AnimationPreset`: reusable phase handlers or keyframes. +- `AnimationOptions`: per-operation keyframes, timing, classes, and lifecycle + callbacks. +- `NgModule.animation(...)`: declarative named preset registration. + +```ts +const app = angular.module("app", ["ng"]); + +app.animation("fade-fast", () => ({ + enter: [{ opacity: 0 }, { opacity: 1 }], + leave: [{ opacity: 1 }, { opacity: 0 }], + options: { duration: 120 }, +})); +``` + +## Core Model + +Animation declarations are stored in one runtime-owned registry. `$animate` is +constructed lazily and resolves injectable preset factories only when an +element requests the named animation. Runtime calls can also define presets +through `$animate.define(...)`. + +Each operation returns an `AnimationHandle`. Starting another animation for the +same element cancels the active handle before running the replacement. + +## Lifecycle Contract + +- Runtime composition owns and clears the animation registry. +- Element animation handles own native animation cancellation and completion. +- Leave operations remove the element only after successful completion. +- Temporary classes, CSS animation overrides, and runtime measurement + properties are restored on completion or cancellation. + +## Reactivity Contract + +The service performs DOM work requested by directives and application code; it +does not own model state or watchers. Reactive directives decide when to invoke +animation operations, and each operation applies its DOM mutation before or +after animation according to the phase contract. + +## Policy Contract + +AngularTS does not add a global animation-policy object. The browser's +`prefers-reduced-motion` setting is always respected. Timing, keyframes, +temporary classes, and lifecycle callbacks belong to `AnimationOptions` or a +named preset. + +## Composition Contract + +`NgModule.animation(...)` writes directly to the runtime's internal +`AnimationRegistry`; there is no injectable animation provider. The full +runtime registers `$animate` as a lazy factory over that registry and the +application injector. + +## Test Harness + +- `animate.spec.ts` covers the registry, service phases, presets, native + handles, reduced motion, lifecycle callbacks, and class mutation. +- `animate.test.ts` runs the browser suite through Playwright. diff --git a/src/animations/animate.spec.ts b/src/animations/animate.spec.ts index 3364eb583..d8469f591 100644 --- a/src/animations/animate.spec.ts +++ b/src/animations/animate.spec.ts @@ -3,6 +3,7 @@ import { Angular } from "../angular.ts"; import { createElementFromHTML, dealoc } from "../shared/dom.ts"; import { wait } from "../shared/test-utils.ts"; +import { AnimationRegistry } from "./animate.ts"; import { addClass as addClassHelper, removeClass as removeClassHelper, @@ -10,6 +11,38 @@ import { updateClass as updateClassHelper, } from "./class-mutation.ts"; +describe("AnimationRegistry", () => { + it("owns normalized custom animation declarations", () => { + const registry = new AnimationRegistry(); + const preset = { enter: [{ opacity: 0 }, { opacity: 1 }] }; + + registry.register(".pulse", preset); + + expect(registry.get("pulse")).toBe(preset); + }); + + it("validates declarations and rejects use after teardown", () => { + const registry = new AnimationRegistry(); + + expect(() => registry.register("", {})).toThrowError( + /Animation name must be a string/, + ); + + registry.destroy(); + registry.destroy(); + + expect(() => registry.register("late", {})).toThrowError( + "Animation registry has already been disposed.", + ); + expect(() => registry.get("pulse")).toThrowError( + "Animation registry has already been disposed.", + ); + expect(() => registry.has("pulse")).toThrowError( + "Animation registry has already been disposed.", + ); + }); +}); + describe("$animate", () => { let host; @@ -70,6 +103,19 @@ describe("$animate", () => { await handle.finished; }); + it("infers enter and move parents from the sibling", async () => { + const anchor = document.createElement("div"); + const entered = document.createElement("div"); + const moved = document.createElement("div"); + + host.append(anchor, moved); + + await $animate.enter(entered, undefined, anchor, { duration: 0 }).finished; + await $animate.move(moved, undefined, entered, { duration: 0 }).finished; + + expect(Array.from(host.children)).toEqual([anchor, entered, moved]); + }); + it("applies class changes directly", async () => { const child = createElementFromHTML('
'); @@ -82,6 +128,22 @@ describe("$animate", () => { expect(child.classList.contains("active")).toBe(false); }); + it("applies combined class changes directly", async () => { + const child = createElementFromHTML( + '
', + ); + + host.append(child); + + await $animate.setClass(child, "new extra", "old", { duration: 1 }) + .finished; + + expect(child.classList.contains("new")).toBe(true); + expect(child.classList.contains("extra")).toBe(true); + expect(child.classList.contains("old")).toBe(false); + expect(child.classList.contains("keep")).toBe(true); + }); + it("runs inline keyframe animations with final styles", async () => { const child = createElementFromHTML('
'); @@ -99,6 +161,24 @@ describe("$animate", () => { expect(child.style.color).toBe("red"); }); + it("supports style-only animations without a named preset or to styles", async () => { + const child = createElementFromHTML('
'); + + host.append(child); + + await $animate.animate( + child, + { opacity: "0" }, + undefined, + "running highlighted", + { duration: 1 }, + ).finished; + + expect(child.style.opacity).toBe("0"); + expect(child.classList.contains("running")).toBe(true); + expect(child.classList.contains("highlighted")).toBe(true); + }); + it("runs CSS custom property enter animations", async () => { style.textContent = ` @keyframes css-fade-in { @@ -207,7 +287,7 @@ describe("$animate", () => { }); host.append(child); - await $animate.leave(child).finished; + await $animate.leave(child, { duration: 1 }).finished; expect(started).toBe(true); expect(host.firstElementChild).toBeNull(); @@ -267,6 +347,41 @@ describe("$animate", () => { expect(child.getAttribute("data-animated")).toBe("true"); }); + it("resolves injectable animation presets once", async () => { + const registeredHost = document.createElement("div"); + const angular = new Angular(); + let factoryCalls = 0; + + angular.module("cached-animations", []).animation("cached", () => { + factoryCalls += 1; + + return { enter: [{ opacity: 0 }, { opacity: 1 }] }; + }); + document.body.append(registeredHost); + angular + .bootstrap(registeredHost, ["cached-animations"]) + .invoke((_$animate_) => { + $animate = _$animate_; + }); + + await $animate.enter( + createElementFromHTML('
'), + registeredHost, + null, + { duration: 1 }, + ).finished; + await $animate.enter( + createElementFromHTML('
'), + registeredHost, + null, + { duration: 1 }, + ).finished; + + expect(factoryCalls).toBe(1); + dealoc(registeredHost); + registeredHost.remove(); + }); + it("ships built-in presets through angular-animate.css", async () => { const child = createElementFromHTML('
'); @@ -310,15 +425,112 @@ describe("$animate", () => { host.append(child); spyOn(child, "animate").and.callThrough(); - await $animate.leave(child, { duration: 1 }).finished; + const handle = $animate.leave(child, { duration: 20 }); + const animation = child.getAnimations()[0]; - const keyframes = child.animate.calls.mostRecent().args[0]; + expect(animation.animationName).toBe("ng-auto-height-leave"); + expect(child.style.getPropertyValue("--ng-animate-auto-height")).toBe( + "40px", + ); + expect(animation.effect.getTiming().duration).toBe(20); + + await handle.finished; - expect(keyframes[0].height).toBe("40px"); - expect(keyframes[1].height).toBe("0px"); + expect(child.animate).not.toHaveBeenCalled(); + expect(child.style.getPropertyValue("--ng-animate-auto-height")).toBe(""); expect(child.style.height).toBe("40px"); }); + it("measures auto-height entry and restores an existing custom property", async () => { + const child = createElementFromHTML( + '
content
', + ); + + Object.defineProperty(child, "scrollHeight", { value: 48 }); + + const handle = $animate.enter(child, host, null); + + expect(child.style.getPropertyValue("--ng-animate-auto-height")).toBe( + "48px", + ); + await handle.finished; + expect(child.style.getPropertyValue("--ng-animate-auto-height")).toBe( + "12px", + ); + }); + + it("falls back to scroll height when leave layout height is zero", async () => { + const child = createElementFromHTML( + '
content
', + ); + + Object.defineProperty(child, "offsetHeight", { value: 0 }); + Object.defineProperty(child, "scrollHeight", { value: 36 }); + host.append(child); + + const handle = $animate.leave(child); + + expect(child.style.getPropertyValue("--ng-animate-auto-height")).toBe( + "36px", + ); + await handle.finished; + }); + + it("runs updates directly when view transitions are unavailable", async () => { + const original = Object.getOwnPropertyDescriptor( + document, + "startViewTransition", + ); + let updated = false; + + Object.defineProperty(document, "startViewTransition", { + configurable: true, + value: undefined, + }); + await $animate.transition(async () => { + await Promise.resolve(); + updated = true; + }); + + expect(updated).toBe(true); + + if (original) { + Object.defineProperty(document, "startViewTransition", original); + } else { + Reflect.deleteProperty(document, "startViewTransition"); + } + }); + + it("waits for native view transitions", async () => { + const original = Object.getOwnPropertyDescriptor( + document, + "startViewTransition", + ); + const calls = []; + + Object.defineProperty(document, "startViewTransition", { + configurable: true, + value(update) { + calls.push("start"); + update(); + + return { + finished: Promise.resolve().then(() => calls.push("finished")), + }; + }, + }); + + await $animate.transition(() => calls.push("update")); + + expect(calls).toEqual(["start", "update", "finished"]); + + if (original) { + Object.defineProperty(document, "startViewTransition", original); + } else { + Reflect.deleteProperty(document, "startViewTransition"); + } + }); + it("runs lifecycle callbacks and removes temporary classes", async () => { const child = createElementFromHTML('
'); @@ -390,7 +602,7 @@ describe("$animate", () => { expect(child.animate).not.toHaveBeenCalled(); }); - it("supports provider registration without class selectors", async () => { + it("supports module registration without class selectors", async () => { const registeredHost = document.createElement("div"); const angular = new Angular(); diff --git a/src/animations/animate.ts b/src/animations/animate.ts index ca513068c..ffa3ebf83 100644 --- a/src/animations/animate.ts +++ b/src/animations/animate.ts @@ -1,4 +1,3 @@ -import { _injector } from "../injection-tokens.ts"; import type { Injectable } from "../interface.ts"; import { assign, @@ -37,7 +36,7 @@ export type AnimationLifecycleCallback = ( context: AnimationContext, ) => void; -export interface NativeAnimationOptions extends KeyframeAnimationOptions { +export interface AnimationOptions extends KeyframeAnimationOptions { animation?: string; keyframes?: Keyframe[] | PropertyIndexedKeyframes; enter?: Keyframe[] | PropertyIndexedKeyframes; @@ -53,12 +52,10 @@ export interface NativeAnimationOptions extends KeyframeAnimationOptions { onCancel?: AnimationLifecycleCallback; } -export type AnimationOptions = NativeAnimationOptions; - export type AnimationPresetHandler = ( element: Element, context: AnimationContext, - options: NativeAnimationOptions, + options: AnimationOptions, ) => AnimationResult; export interface AnimationPreset { @@ -199,56 +196,45 @@ export interface AnimateService { element: Element, parent?: ParentNode | null, after?: ChildNode | null, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; move( element: Element, parent: ParentNode | null, after?: ChildNode | null, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; - leave(element: Element, options?: NativeAnimationOptions): AnimationHandle; + leave(element: Element, options?: AnimationOptions): AnimationHandle; addClass( element: Element, className: string, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; removeClass( element: Element, className: string, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; setClass( element: Element, add: string, remove: string, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; animate( element: Element, from: Record, to?: Record, className?: string, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): AnimationHandle; transition(update: () => void | Promise): Promise; } type PresetRegistration = AnimationPreset | Injectable<() => AnimationPreset>; -export interface AnimateProvider { - /** @internal */ - _registeredAnimations: Partial>; - /** @internal */ - _customAnimationNames: Set; - register(name: string, preset: PresetRegistration): void; - $get: [string, ($injector: ng.InjectorService) => AnimateService]; -} - const DEFAULT_DURATION = 150; -const DEFAULT_EASING = "cubic-bezier(0.2, 0, 0, 1)"; - const CSS_ANIMATION_PROPERTIES: Record = { enter: "--ng-enter-animation", leave: "--ng-leave-animation", @@ -265,327 +251,322 @@ const CSS_BUILT_IN_PRESETS = new Set([ "scale", "slide-start", "slide-end", + "collapse", + "expand", ]); -const BUILT_IN_PRESETS: Record = { - // CSS-defined presets live in css/angular.css. Height presets stay here - // because they need runtime measurements before each animation. - collapse: { - enter: expandHeight, - leave: collapseHeight, - options: { duration: 200, easing: DEFAULT_EASING, fill: "both" }, - }, - expand: { - enter: expandHeight, - leave: collapseHeight, - options: { duration: 200, easing: DEFAULT_EASING, fill: "both" }, - }, -}; - -AnimateProvider.$inject = [] as string[]; +/** @internal */ +export class AnimationRegistry { + private readonly _registrations = new Map(); + private _destroyed = false; -export function AnimateProvider(this: AnimateProvider): void { - /** @internal */ - this._registeredAnimations = { ...BUILT_IN_PRESETS }; - /** @internal */ - this._customAnimationNames = new Set(); + register(name: string, preset: PresetRegistration): void { + this.assertActive(); - this.register = (name: string, preset: PresetRegistration): void => { if (!name || !isString(name)) { throw $animateError("noname", "Animation name must be a string."); } const normalizedName = normalizeAnimationName(name); - this._registeredAnimations[normalizedName] = preset; - this._customAnimationNames.add(normalizedName); - }; + this._registrations.set(normalizedName, preset); + } - this.$get = [ - _injector, - ($injector: ng.InjectorService): AnimateService => { - const resolvedPresets = new Map(); + get(name: string): PresetRegistration | undefined { + this.assertActive(); - const activeHandles = new WeakMap(); + return this._registrations.get(name); + } - const resolvePreset = ( - element: Element, - options?: NativeAnimationOptions, - ) => { - const name = animationNameFor(element, options); + has(name: string): boolean { + this.assertActive(); - if (!name) return undefined; + return this._registrations.has(name); + } - if (resolvedPresets.has(name)) { - return { - preset: assertDefined(resolvedPresets.get(name)), - custom: this._customAnimationNames.has(name), - }; - } + destroy(): void { + if (this._destroyed) return; - const registration = this._registeredAnimations[name]; + this._destroyed = true; + this._registrations.clear(); + } - if (!registration) return undefined; + private assertActive(): void { + if (this._destroyed) { + throw new Error("Animation registry has already been disposed."); + } + } +} - const preset = ( - isFunction(registration) || Array.isArray(registration) - ? $injector.invoke( - registration as Injectable<() => AnimationPreset>, - ) - : registration - ) as AnimationPreset; +/** @internal */ +export function createAnimateService( + registry: AnimationRegistry, + $injector: ng.InjectorService, +): AnimateService { + const resolvedPresets = new Map< + string, + { registration: PresetRegistration; preset: AnimationPreset } + >(); - resolvedPresets.set(name, preset); + const activeHandles = new WeakMap(); - return { - preset, - custom: this._customAnimationNames.has(name), - }; - }; + const resolvePreset = (element: Element, options?: AnimationOptions) => { + const name = animationNameFor(element, options); - const run = ( - phase: AnimationPhase, - element: Element, - options: NativeAnimationOptions = {}, - contextOverrides: Partial = {}, - cleanup?: (ok: boolean) => void, - ): AnimationHandle => { - const controller = new AbortController(); + if (!name) return undefined; - activeHandles.get(element)?.cancel(); - activeHandles.delete(element); + const registration = registry.get(name); - const context: AnimationContext = { - phase, - signal: controller.signal, - ...contextOverrides, - }; + if (!registration) return undefined; - const tempClasses = splitOptionClasses(options.tempClasses); + const resolved = resolvedPresets.get(name); - const elementClassList = element.classList; + if (resolved?.registration === registration) { + return resolved.preset; + } - if (tempClasses.length) { - elementClassList.add(...tempClasses); - } + const preset = + isFunction(registration) || Array.isArray(registration) + ? $injector.invoke(registration) + : registration; - const animationName = animationNameFor(element, options); + resolvedPresets.set(name, { registration, preset }); - const cssPresetClass = - animationName && !this._registeredAnimations[animationName] - ? cssPresetClassFor(animationName) - : undefined; + return preset; + }; - const finishCleanup = (ok: boolean): void => { - if (tempClasses.length) { - elementClassList.remove(...tempClasses); - } + const run = ( + phase: AnimationPhase, + element: Element, + options: AnimationOptions = {}, + contextOverrides: Partial = {}, + cleanup?: (ok: boolean) => void, + ): AnimationHandle => { + const controller = new AbortController(); - if (cssPresetClass) { - elementClassList.remove(cssPresetClass); - } + activeHandles.get(element)?.cancel(); + activeHandles.delete(element); - if (ok) { - options.onDone?.(element, context); - } else { - options.onCancel?.(element, context); - } + const context: AnimationContext = { + phase, + signal: controller.signal, + ...contextOverrides, + }; - cleanup?.(ok); - }; + const tempClasses = splitOptionClasses(options.tempClasses); - if (shouldSkipAnimation(element, options)) { - finishCleanup(true); + const elementClassList = element.classList; - return new AnimationHandle(undefined, controller); - } + if (tempClasses.length) { + elementClassList.add(...tempClasses); + } - options.onStart?.(element, context); + const animationName = animationNameFor(element, options); - if (cssPresetClass) { - elementClassList.add(cssPresetClass); - } + const cssPresetClass = + animationName && !registry.has(animationName) + ? cssPresetClassFor(animationName) + : undefined; - const resolvedPreset = resolvePreset(element, options); + const finishCleanup = (ok: boolean): void => { + if (tempClasses.length) { + elementClassList.remove(...tempClasses); + } - const preset = resolvedPreset?.preset; + if (cssPresetClass) { + elementClassList.remove(cssPresetClass); + } - const handler = preset?.[phase]; + if (ok) { + options.onDone?.(element, context); + } else { + options.onCancel?.(element, context); + } - let result: AnimationResult | AnimationResult[]; + cleanup?.(ok); + }; - let animationCleanup: (() => void) | undefined; + if (shouldSkipAnimation(element, options)) { + finishCleanup(true); - if (isFunction(handler)) { - result = handler(element, context, options); - } else { - const optionKeyframes = keyframesForPhase(phase, options); + return new AnimationHandle(undefined, controller); + } - const customKeyframes = - resolvedPreset?.custom && handler ? handler : undefined; + options.onStart?.(element, context); - const keyframes = optionKeyframes ?? customKeyframes; + if (cssPresetClass) { + elementClassList.add(cssPresetClass); + } - const cssAnimation = keyframes - ? undefined - : cssAnimationForPhase(element, phase); + const resolvedPreset = resolvePreset(element, options); - const presetKeyframes = - !cssAnimation && !resolvedPreset?.custom ? handler : undefined; + const preset = resolvedPreset; - if (keyframes) { - result = element.animate( - keyframes, - animationOptionsFor(preset, options), - ); - } else if (cssAnimation) { - const cssResult = runCssAnimation(element, cssAnimation); - - result = cssResult.animations; - animationCleanup = cssResult.cleanup; - } else if (presetKeyframes) { - result = element.animate( - presetKeyframes, - animationOptionsFor(preset, options), - ); - } else { - const styleKeyframes = keyframesFromStyles( - context.from, - context.to, - ); - - result = styleKeyframes - ? element.animate( - styleKeyframes, - animationOptionsFor(preset, options), - ) - : undefined; - } + const handler = preset?.[phase]; + + let result: AnimationResult | AnimationResult[]; + + let animationCleanup: (() => void) | undefined; + + const cssPresetCleanup = animationName + ? prepareCssPreset(element, animationName, phase) + : undefined; + + if (isFunction(handler)) { + result = handler(element, context, options); + } else { + const optionKeyframes = keyframesForPhase(phase, options); + + const keyframes = optionKeyframes ?? handler; + + const cssAnimation = keyframes + ? undefined + : cssAnimationForPhase(element, phase); + + if (keyframes) { + result = element.animate( + keyframes, + animationOptionsFor(preset, options), + ); + } else if (cssAnimation) { + const cssResult = runCssAnimation(element, cssAnimation); + + if (animationName && isAutoHeightPreset(animationName)) { + applyCssAnimationTiming(cssResult.animations, options); } + result = cssResult.animations; + animationCleanup = cssResult.cleanup; + } else { + const styleKeyframes = keyframesFromStyles(context.from, context.to); + + result = styleKeyframes + ? element.animate( + styleKeyframes, + animationOptionsFor(preset, options), + ) + : undefined; + } + } - const handle = new AnimationHandle(result, controller, (ok) => { - animationCleanup?.(); - finishCleanup(ok); - }); + const handle = new AnimationHandle(result, controller, (ok) => { + animationCleanup?.(); + cssPresetCleanup?.(); + finishCleanup(ok); + }); - activeHandles.set(element, handle); - handle.done(() => { - if (activeHandles.get(element) === handle) { - activeHandles.delete(element); - } - }); + activeHandles.set(element, handle); + handle.done(() => { + if (activeHandles.get(element) === handle) { + activeHandles.delete(element); + } + }); - return handle; - }; + return handle; + }; - return { - cancel(handle?: AnimationHandle): void { - handle?.cancel(); - }, + return { + cancel(handle?: AnimationHandle): void { + handle?.cancel(); + }, - define: (name, preset): void => { - this.register(name, preset); - resolvedPresets.delete(normalizeAnimationName(name)); - }, + define: (name, preset): void => { + registry.register(name, preset); + }, - enter: (element, parent, after, options) => { - domInsert(element, assertDefined(parent ?? after?.parentNode), after); + enter: (element, parent, after, options) => { + domInsert(element, assertDefined(parent ?? after?.parentNode), after); - return run("enter", element, options); - }, + return run("enter", element, options); + }, - move: (element, parent, after, options) => { - domInsert(element, assertDefined(parent ?? after?.parentNode), after); + move: (element, parent, after, options) => { + domInsert(element, assertDefined(parent ?? after?.parentNode), after); - return run("move", element, options); - }, + return run("move", element, options); + }, - leave: (element, options) => - run("leave", element, options, {}, (ok) => { - if (ok) removeElement(element); - }), + leave: (element, options) => + run("leave", element, options, {}, (ok) => { + if (ok) removeElement(element); + }), - addClass: (element, className, options) => { - const nextOptions = { ...options, addClass: className }; + addClass: (element, className, options) => { + const nextOptions = { ...options, addClass: className }; - element.classList.add(...splitClasses(className)); + element.classList.add(...splitClasses(className)); - return run("addClass", element, nextOptions, { - className, - addClass: className, - }); - }, + return run("addClass", element, nextOptions, { + className, + addClass: className, + }); + }, - removeClass: (element, className, options) => { - const nextOptions = { ...options, removeClass: className }; + removeClass: (element, className, options) => { + const nextOptions = { ...options, removeClass: className }; - element.classList.remove(...splitClasses(className)); + element.classList.remove(...splitClasses(className)); - return run("removeClass", element, nextOptions, { - className, - removeClass: className, - }); - }, + return run("removeClass", element, nextOptions, { + className, + removeClass: className, + }); + }, - setClass: (element, add, remove, options) => { - const nextOptions = { - ...options, - addClass: add, - removeClass: remove, - }; + setClass: (element, add, remove, options) => { + const nextOptions = { + ...options, + addClass: add, + removeClass: remove, + }; - element.classList.add(...splitClasses(add)); - element.classList.remove(...splitClasses(remove)); + element.classList.add(...splitClasses(add)); + element.classList.remove(...splitClasses(remove)); - return run("setClass", element, nextOptions, { - addClass: add, - removeClass: remove, - }); - }, + return run("setClass", element, nextOptions, { + addClass: add, + removeClass: remove, + }); + }, - animate: (element, from, to, className, options) => { - const toStyles = to ?? {}; + animate: (element, from, to, className, options) => { + const toStyles = to ?? {}; - if (className) element.classList.add(...splitClasses(className)); + if (className) element.classList.add(...splitClasses(className)); - assign((element as HTMLElement).style, from); + assign((element as HTMLElement).style, from); - return run( - "animate", - element, - { ...options, from, to: toStyles }, - { from, to: toStyles, className }, - () => { - assign((element as HTMLElement).style, toStyles); - }, - ); + return run( + "animate", + element, + { ...options, from, to: toStyles }, + { from, to: toStyles, className }, + () => { + assign((element as HTMLElement).style, toStyles); }, + ); + }, - async transition(update): Promise { - type ViewTransitionStarter = ( - callback: () => void | Promise, - ) => { - finished: Promise; - }; - const startViewTransition = Reflect.get( - document, - "startViewTransition", - ) as unknown; - - if (!isFunction(startViewTransition)) { - await update(); - - return; - } - - await (startViewTransition as ViewTransitionStarter).call( - document, - update, - ).finished; - }, + async transition(update): Promise { + type ViewTransitionStarter = (callback: () => void | Promise) => { + finished: Promise; }; + const startViewTransition = Reflect.get( + document, + "startViewTransition", + ) as unknown; + + if (!isFunction(startViewTransition)) { + await update(); + + return; + } + + await (startViewTransition as ViewTransitionStarter).call( + document, + update, + ).finished; }, - ]; + }; } function splitClasses(className: string): string[] { @@ -610,9 +591,44 @@ function cssPresetClassFor(name: string): string | undefined { : undefined; } +function prepareCssPreset( + element: Element, + name: string, + phase: AnimationPhase, +): (() => void) | undefined { + if ( + !isAutoHeightPreset(name) || + (phase !== "enter" && phase !== "leave") || + !(element instanceof HTMLElement) + ) { + return undefined; + } + + const property = "--ng-animate-auto-height"; + const previousHeight = element.style.getPropertyValue(property); + const height = + phase === "enter" + ? element.scrollHeight + : element.offsetHeight || element.scrollHeight; + + element.style.setProperty(property, `${String(height)}px`); + + return () => { + if (previousHeight) { + element.style.setProperty(property, previousHeight); + } else { + element.style.removeProperty(property); + } + }; +} + +function isAutoHeightPreset(name: string): boolean { + return name === "collapse" || name === "expand"; +} + function animationNameFor( element: Element, - options?: NativeAnimationOptions, + options?: AnimationOptions, ): string | undefined { const explicit = options?.animation; @@ -630,7 +646,7 @@ function animationNameFor( function keyframesForPhase( phase: AnimationPhase, - options: NativeAnimationOptions, + options: AnimationOptions, ): Keyframe[] | PropertyIndexedKeyframes | undefined { if (options.keyframes) return options.keyframes; @@ -654,7 +670,7 @@ function keyframesFromStyles( function animationOptionsFor( preset: AnimationPreset | undefined, - options: NativeAnimationOptions, + options: AnimationOptions, ): KeyframeAnimationOptions { const defaults = preset?.options ?? {}; @@ -749,9 +765,40 @@ function runCssAnimation( }; } +function applyCssAnimationTiming( + animations: Animation[], + options: AnimationOptions, +): void { + const timing: OptionalEffectTiming = {}; + const timingKeys = [ + "delay", + "direction", + "duration", + "easing", + "endDelay", + "fill", + "iterationStart", + "iterations", + ] as const; + + timingKeys.forEach((key) => { + const value = options[key]; + + if (value !== undefined) { + Object.assign(timing, { [key]: value }); + } + }); + + if (Object.keys(timing).length === 0) return; + + animations.forEach((animation) => { + animation.effect?.updateTiming(timing); + }); +} + function shouldSkipAnimation( element: Element, - options: NativeAnimationOptions, + options: AnimationOptions, ): boolean { if (!("animate" in element)) return true; @@ -761,101 +808,3 @@ function shouldSkipAnimation( return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } - -function expandHeight( - element: Element, - context: AnimationContext, - options: NativeAnimationOptions, -): AnimationResult { - const target = element as HTMLElement; - - const previousOverflow = target.style.overflow; - - const previousHeight = target.style.height; - - const previousOpacity = target.style.opacity; - - const height = `${String(target.scrollHeight)}px`; - - target.style.overflow = "hidden"; - - const animation = target.animate( - [ - { height: "0px", opacity: 0 }, - { height, opacity: 1 }, - ], - animationOptionsFor(BUILT_IN_PRESETS.expand, options), - ); - - cleanupHeightAnimation(target, context, animation, { - height: previousHeight, - opacity: previousOpacity, - overflow: previousOverflow, - }); - - return animation; -} - -function collapseHeight( - element: Element, - context: AnimationContext, - options: NativeAnimationOptions, -): AnimationResult { - const target = element as HTMLElement; - - const previousOverflow = target.style.overflow; - - const previousHeight = target.style.height; - - const previousOpacity = target.style.opacity; - - const height = `${String(target.offsetHeight || target.scrollHeight)}px`; - - target.style.height = height; - target.style.overflow = "hidden"; - - const animation = target.animate( - [ - { height, opacity: 1 }, - { height: "0px", opacity: 0 }, - ], - animationOptionsFor(BUILT_IN_PRESETS.collapse, options), - ); - - cleanupHeightAnimation(target, context, animation, { - height: previousHeight, - opacity: previousOpacity, - overflow: previousOverflow, - }); - - return animation; -} - -function cleanupHeightAnimation( - element: HTMLElement, - context: AnimationContext, - animation: Animation, - previous: { height: string; opacity: string; overflow: string }, -): void { - let cleaned = false; - - const cleanup = () => { - if (cleaned) return; - - cleaned = true; - element.style.height = previous.height; - element.style.opacity = previous.opacity; - element.style.overflow = previous.overflow; - }; - - context.signal.addEventListener("abort", cleanup, { once: true }); - void animation.finished - .then(() => { - cleanup(); - - return undefined; - }) - .catch(() => { - cleanup(); - }); -} diff --git a/src/animations/lazy-animate.ts b/src/animations/lazy-animate.ts index 12ca85553..28b727f04 100644 --- a/src/animations/lazy-animate.ts +++ b/src/animations/lazy-animate.ts @@ -10,7 +10,7 @@ export type LazyAnimate = () => ng.AnimateService; export function createLazyAnimate($injector: ng.InjectorService): LazyAnimate { let $animate: ng.AnimateService | undefined; - return () => ($animate ??= $injector.get(_animate) as ng.AnimateService); + return () => ($animate ??= $injector.get(_animate)); } /** diff --git a/src/animations/router-view-transition-demo.html b/src/animations/router-view-transition-demo.html index d56dcfc02..57a5081f7 100644 --- a/src/animations/router-view-transition-demo.html +++ b/src/animations/router-view-transition-demo.html @@ -7,14 +7,12 @@ @@ -242,9 +238,9 @@

Settings

diff --git a/src/router/router.html b/src/router/router.html index 95e7b7cbd..3e506f83b 100644 --- a/src/router/router.html +++ b/src/router/router.html @@ -12,6 +12,7 @@ + diff --git a/src/router/router.ts b/src/router/router.ts index b31c50e52..de2a15123 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -1,9 +1,5 @@ -import { - _injector, - _location, - _locationProvider, -} from "../injection-tokens.ts"; import { assign } from "../shared/utils.ts"; +import { ParamType } from "./params/param-type.ts"; import { ParamFactory } from "./params/param-factory.ts"; import { createDefaultParamTypes, @@ -13,19 +9,61 @@ import { StateParams } from "./params/state-params.ts"; import { RouteTable } from "./route-table.ts"; import { RouterUrlRuntime } from "./router-url.ts"; import { UrlMatcher, type UrlMatcherCompileConfig } from "./url/url-matcher.ts"; -import type { RawParams } from "./params/interface.ts"; -import type { StateDeclaration } from "./state/interface.ts"; +import type { ParamTypeDefinition, RawParams } from "./params/interface.ts"; +import type { + StateDeclaration, + StateRetentionPolicyDeclaration, + StateTransitionPolicyDeclaration, +} from "./state/interface.ts"; import type { Transition } from "./transition/transition.ts"; +import type { InternalTransitionOptions } from "./transition/interface.ts"; import type { StateObject } from "./state/state-object.ts"; +import type { StateRuntime } from "./state/state-service.ts"; +import type { LocationConfig } from "../services/location/location.ts"; + +export interface RouterScrollOptions { + behavior?: ScrollBehavior; + left?: number; + top?: number; + selector?: string; +} + +export type RouterScrollConfig = + | boolean + | "top" + | "hash" + | "preserve" + | RouterScrollOptions; + +export interface RouterFocusOptions { + selector?: string; + preventScroll?: boolean; +} + +export type RouterFocusConfig = boolean | string | RouterFocusOptions; + +export interface RouterConfig { + strict?: boolean; + caseInsensitive?: boolean; + defaultSquashPolicy?: boolean | string; + paramTypes?: Record>; + scroll?: RouterScrollConfig; + focus?: RouterFocusConfig; + viewTransitions?: boolean; + loading?: StateTransitionPolicyDeclaration["loading"]; + retry?: StateTransitionPolicyDeclaration["retry"]; + fallbackTo?: StateTransitionPolicyDeclaration["fallbackTo"]; + error?: StateTransitionPolicyDeclaration["error"]; + errorBoundary?: StateTransitionPolicyDeclaration["errorBoundary"]; + retention?: StateRetentionPolicyDeclaration; +} /** * Mutable router state/config shared across state, URL, and transition services. */ -export class RouterProvider { - /* @ignore */ static $inject = [_locationProvider]; - +export class RouterRuntimeState { /** @internal */ - _stateService: ng.StateService | undefined; + _stateService: StateRuntime | undefined; /** @internal */ _routeTable: RouteTable; /** @internal */ @@ -43,6 +81,24 @@ export class RouterProvider { /** @internal */ _params: StateParams; /** @internal */ + _scroll: RouterScrollConfig | undefined; + /** @internal */ + _focus: RouterFocusConfig | undefined; + /** @internal */ + _viewTransitions: boolean | undefined; + /** @internal */ + _loading: StateTransitionPolicyDeclaration["loading"] | undefined; + /** @internal */ + _retry: StateTransitionPolicyDeclaration["retry"] | undefined; + /** @internal */ + _fallbackTo: StateTransitionPolicyDeclaration["fallbackTo"] | undefined; + /** @internal */ + _error: StateTransitionPolicyDeclaration["error"] | undefined; + /** @internal */ + _errorBoundary: StateTransitionPolicyDeclaration["errorBoundary"] | undefined; + /** @internal */ + _retention: StateRetentionPolicyDeclaration | undefined; + /** @internal */ _lastStartedTransitionId: number; /** @internal */ _lastStartedTransition: Transition | undefined; @@ -62,16 +118,25 @@ export class RouterProvider { /** * Creates the shared mutable router globals container. */ - constructor($locationProvider: ng.LocationProvider) { + constructor(locationConfig: LocationConfig) { this._stateService = undefined; this._routeTable = new RouteTable(); - this._urlRuntime = new RouterUrlRuntime($locationProvider); + this._urlRuntime = new RouterUrlRuntime(locationConfig); this._isCaseInsensitive = false; this._isStrictMode = true; this._defaultSquashPolicy = false; this._paramTypes = createDefaultParamTypes(); this._paramFactory = new ParamFactory(this); this._params = new StateParams(); + this._scroll = undefined; + this._focus = undefined; + this._viewTransitions = undefined; + this._loading = undefined; + this._retry = undefined; + this._fallbackTo = undefined; + this._error = undefined; + this._errorBoundary = undefined; + this._retention = undefined; this._lastStartedTransitionId = -1; this._lastStartedTransition = undefined; this._lastSuccessfulTransition = undefined; @@ -96,19 +161,75 @@ export class RouterProvider { return this._defaultSquashPolicy; } - $get = [ - _location, - _injector, - /** - * Returns the singleton router internals instance. - */ - ($location: ng.LocationService, $injector: ng.InjectorService) => { - this._urlRuntime._init($location); - this._paramFactory._injector = $injector; + config(config: RouterConfig): void { + if (config.strict !== undefined) { + this._isStrictMode = config.strict; + } + + if (config.caseInsensitive !== undefined) { + this._isCaseInsensitive = config.caseInsensitive; + } + + if (config.defaultSquashPolicy !== undefined) { + this._defaultSquashPolicy = config.defaultSquashPolicy; + } + + if (config.paramTypes !== undefined) { + for (const [name, definition] of Object.entries(config.paramTypes)) { + this._paramTypes[name] = new ParamType({ + name, + ...definition, + }); + } + } + + if (config.scroll !== undefined) { + this._scroll = config.scroll; + } + + if (config.focus !== undefined) { + this._focus = config.focus; + } + + if (config.viewTransitions !== undefined) { + this._viewTransitions = config.viewTransitions; + } + + if (config.loading !== undefined) { + this._loading = config.loading; + } + + if (config.retry !== undefined) { + this._retry = config.retry; + } + + if (config.fallbackTo !== undefined) { + this._fallbackTo = config.fallbackTo; + } + + if (config.error !== undefined) { + this._error = config.error; + } + + if (config.errorBoundary !== undefined) { + this._errorBoundary = config.errorBoundary; + } + + if (config.retention !== undefined) { + this._retention = config.retention; + } + } + + /** @internal */ + _initRuntime( + $location: ng.LocationService, + $injector: ng.InjectorService, + ): this { + this._urlRuntime._init($location); + this._paramFactory._injector = $injector; - return this; - }, - ]; + return this; + } /** @internal */ _sync(evt?: ng.ScopeEvent): void { @@ -136,7 +257,10 @@ export class RouterProvider { const currentHref = current ? $state.href(current, this._params) : null; if ($state.href(state, params) !== currentHref) { - void $state.transitionTo(state, params, { inherit: true, source: "url" }); + void $state.transitionTo(state, params, { + inherit: true, + source: "url", + } as InternalTransitionOptions); } } diff --git a/src/router/router/template-factory.spec.ts b/src/router/router/template-factory.spec.ts index c2b7a3521..4d3c24eed 100644 --- a/src/router/router/template-factory.spec.ts +++ b/src/router/router/template-factory.spec.ts @@ -5,7 +5,7 @@ import { waitUntil } from "../../shared/test-utils.ts"; describe("templateFactory", () => { let $injector: any, - $templateFactory: any, + templateFactory: any, $sce, $scope: any, $compile: any, @@ -26,53 +26,46 @@ describe("templateFactory", () => { $injector = window.angular.bootstrap(document.getElementById("app")!, [ "defaultModule", ]); - $injector.invoke( - (_$templateFactory_: any, _$sce_: any, $rootScope: any) => { - $templateFactory = _$templateFactory_; - $sce = _$sce_; - $scope = $rootScope; - }, - ); + $injector.invoke((_$state_: any, _$sce_: any, $rootScope: any) => { + templateFactory = _$state_._viewService._templateFactory; + $sce = _$sce_; + $scope = $rootScope; + }); }); - it("exists", () => { - expect($injector.get("$templateFactory")).toBeDefined(); + it("is owned by the state runtime", () => { + expect(templateFactory).toBeDefined(); }); describe("template URL behavior", () => { it("fetches relative URLs correctly", async () => { - const res = await $templateFactory._fromUrl("/mock/hello"); + const res = await templateFactory._fromUrl("/mock/hello"); expect(await res).toEqual("Hello"); }); it("fetches cross-domain URLs without SCE restrictions", async () => { const url = "http://evil.com/views/view.html"; + const fetch = spyOn(window, "fetch").and.resolveTo( + new Response("Cross-domain template"), + ); - let templateData; - - try { - templateData = await $templateFactory._fromUrl(url); - } catch (e) { - templateData = null; // fetch failed (404, network, etc.) - } + const templateData = await templateFactory._fromUrl(url); - // Angular.ts trusts the URL, so no SCE errors should occur - expect(templateData !== undefined).toBe(true); + expect(fetch).toHaveBeenCalledWith(url, jasmine.any(Object)); + expect(templateData).toBe("Cross-domain template"); }); it("allows URLs marked as trusted explicitly (optional, passes through)", async () => { const url = "http://example.com/trusted.html"; + const fetch = spyOn(window, "fetch").and.resolveTo( + new Response("Trusted template"), + ); - let templateData; - - try { - templateData = await $templateFactory._fromUrl(url); - } catch (e) { - templateData = null; - } + const templateData = await templateFactory._fromUrl(url); - expect(templateData !== undefined).toBe(true); + expect(fetch).toHaveBeenCalledWith(url, jasmine.any(Object)); + expect(templateData).toBe("Trusted template"); }); }); @@ -89,14 +82,13 @@ describe("templateFactory", () => { ]); $injector.invoke( ( - _$templateFactory_: any, _$sce_: any, $rootScope: any, _$stateRegistry_: any, _$state_: any, _$compile_: any, ) => { - $templateFactory = _$templateFactory_; + templateFactory = _$state_._viewService._templateFactory; $sce = _$sce_; $scope = $rootScope; $stateRegistry = _$stateRegistry_; diff --git a/src/router/router/template-factory.ts b/src/router/router/template-factory.ts index dbd716663..9772baf58 100644 --- a/src/router/router/template-factory.ts +++ b/src/router/router/template-factory.ts @@ -1,15 +1,13 @@ -import { _injector, _templateRequest } from "../../injection-tokens.ts"; import { isArray, isDefined, isFunction, isNullOrUndefined, - isObject, isString, keys, } from "../../shared/utils.ts"; import { annotate } from "../../core/di/di.ts"; -import { DirectiveSuffix } from "../../core/compile/compile.ts"; +import type { CompileRegistry } from "../../core/compile/compile.ts"; import { kebobString } from "../../shared/strings.ts"; import type { ResolveContext } from "../resolve/resolve-context.ts"; import type { RawParams } from "../params/interface.ts"; @@ -62,28 +60,23 @@ function componentElementName(camelCase: string): string { /** * Resolves route templates and components from state view declarations. */ -export class TemplateFactoryProvider { +export class TemplateFactoryService { /** @internal */ - _templateRequest: ng.TemplateRequestService | undefined; + _templateRequest: ng.TemplateRequestService; /** @internal */ - _injector: ng.InjectorService | undefined; + _compileRegistry: CompileRegistry; + /** @internal */ + _injector: ng.InjectorService; - /** - * Wires template request and injector services into the factory. - */ - $get = [ - _templateRequest, - _injector, - ( - $templateRequest: ng.TemplateRequestService, - $injector: ng.InjectorService, - ): TemplateFactoryProvider => { - this._templateRequest = $templateRequest; - this._injector = $injector; - - return this; - }, - ]; + constructor( + compileRegistry: CompileRegistry, + $templateRequest: ng.TemplateRequestService, + $injector: ng.InjectorService, + ) { + this._compileRegistry = compileRegistry; + this._templateRequest = $templateRequest; + this._injector = $injector; + } /** * Resolves a state's view config into either concrete template HTML or a component name. @@ -96,7 +89,7 @@ export class TemplateFactoryProvider { const { template, templateUrl, component } = config; if (isDefined(template)) { - return asTemplate(TemplateFactoryProvider._fromString(template, params)); + return asTemplate(TemplateFactoryService._fromString(template, params)); } if (isDefined(templateUrl)) { @@ -153,7 +146,10 @@ export class TemplateFactoryProvider { bindings?: Record, ): string { bindings = bindings ?? {}; - const componentBindings = getComponentBindings(this._injector, component); + const componentBindings = getComponentBindings( + this._compileRegistry, + component, + ); const attrs: string[] = []; @@ -170,10 +166,6 @@ export class TemplateFactoryProvider { /** @internal */ _getTemplateRequest(): ng.TemplateRequestService { - if (!this._templateRequest) { - throw new Error("$templateRequest is not available"); - } - return this._templateRequest; } } @@ -222,23 +214,19 @@ function componentAttributeTemplate( * Reads the binding declarations for a named component directive. */ function getComponentBindings( - $injector: ng.InjectorService | undefined, + compileRegistry: CompileRegistry, name: string, ): BindingTuple[] { - const cmpDefs = $injector?.get(name + DirectiveSuffix) as - | ng.Directive[] - | undefined; + const componentBindings = compileRegistry.getComponentBindings(name); - if (!cmpDefs?.length) { + if (!componentBindings?.length) { throw new Error(`Unable to find component named '${name}'`); } const bindings: BindingTuple[] = []; - cmpDefs.forEach((def) => { - const defBindings = getBindings(def); - - defBindings.forEach((binding) => { + componentBindings.forEach((definition) => { + scopeBindings(definition).forEach((binding) => { bindings.push(binding); }); }); @@ -246,20 +234,6 @@ function getComponentBindings( return bindings; } -function getBindings(def: ng.Directive): BindingTuple[] { - const componentBindings = def.bindToController; - - if ( - isObject(componentBindings) && - isObject(def.scope) && - !keys(def.scope).length - ) { - return scopeBindings(componentBindings); - } - - return []; -} - function scopeBindings(bindingsObj: Record): BindingTuple[] { const bindingKeys = keys(bindingsObj); diff --git a/src/router/routing-policy-example.test.ts b/src/router/routing-policy-example.test.ts new file mode 100644 index 000000000..821177d2a --- /dev/null +++ b/src/router/routing-policy-example.test.ts @@ -0,0 +1,283 @@ +import { expect, test } from "@playwright/test"; + +const TEST_URL = "docs/static/examples/routing-policy/policy.html"; +const RETRY_FALLBACK_TEST_URL = + "docs/static/examples/routing-policy/retry-fallback.html"; +const RETENTION_TEST_URL = "docs/static/examples/routing-policy/retention.html"; +const PARAM_TYPES_TEST_URL = "docs/static/examples/routing/param-types.html"; +const STATE_LINKS_TEST_URL = "docs/static/examples/routing/state-links.html"; +const SCROLL_FOCUS_TEST_URL = "docs/static/examples/routing/scroll-focus.html"; + +test("route policy docs example applies inherited and child navigation policies", async ({ + page, +}) => { + await page.goto(TEST_URL); + + await expect( + page.getByRole("heading", { name: "Route policy demo" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Admin users" }).click(); + await expect(page.getByTestId("current-state")).toContainText("login"); + + await page.getByRole("button", { name: "Public help" }).click(); + await expect(page.getByTestId("current-state")).toContainText("admin.help"); + + await page.getByRole("button", { name: "Sign in" }).click(); + await page.getByRole("button", { name: "Admin users" }).click(); + await expect(page.getByTestId("current-state")).toContainText("admin.users"); + + await page.getByRole("button", { name: "Admin roles" }).click(); + await expect(page.getByTestId("current-state")).toContainText("forbidden"); + + await page.getByRole("button", { name: "Grant roles" }).click(); + await page.getByRole("button", { name: "Admin roles" }).click(); + await expect(page.getByTestId("current-state")).toContainText("admin.roles"); +}); + +test("route boundary docs example applies loading, fallback, and error boundaries", async ({ + page, +}) => { + const diagnostics = async () => + page.evaluate(() => + JSON.parse( + JSON.stringify((window as any).routingRetryFallbackDiagnostics), + ), + ); + const activeViewState = async () => + page.evaluate(() => { + const injector = (window as any).angular.getInjector( + document.getElementById("routing-retry-fallback-app"), + ); + const view = injector.get("$state")._viewService; + const nestedView = view._ngViews.find( + (ngView: any) => ngView._fqn === "demo.$default", + ); + + return nestedView?._config?._path.at(-1).state.name ?? null; + }); + + await page.goto(RETRY_FALLBACK_TEST_URL); + + await expect( + page.getByRole("heading", { name: "Router retry and fallback" }), + ).toBeVisible(); + await expect(page.locator("#current-state")).toContainText("demo.base"); + + let successStart = (await diagnostics()).succeeded.length; + + await page.getByRole("button", { name: "Transient state" }).click(); + + await expect(page.locator("#current-state")).toContainText("demo.transient"); + await expect(page.locator("#last-action")).toContainText( + "entered demo.transient", + ); + const transientSuccesses = (await diagnostics()).succeeded.slice( + successStart, + ); + expect(transientSuccesses).toContain("demo.transient"); + await expect.poll(activeViewState).toBe("demo.transient"); + await expect(page.getByText("Transient state payload: ready")).toBeVisible(); + + successStart = (await diagnostics()).succeeded.length; + + await page.getByRole("button", { name: "Stable failure" }).click(); + + await expect(page.locator("#current-state")).toContainText("demo.fallback"); + await expect(page.locator("#last-action")).toContainText( + "entered demo.fallback", + ); + const fallbackSuccesses = (await diagnostics()).succeeded.slice(successStart); + expect(fallbackSuccesses).toContain("demo.loading"); + expect(fallbackSuccesses).toContain("demo.fallback"); + await expect.poll(activeViewState).toBe("demo.fallback"); + await expect( + page.getByText("Fallback recovery route reached."), + ).toBeVisible(); + + await page.getByRole("button", { name: "Reset" }).click(); + + await expect(page.locator("#current-state")).toContainText("demo.base"); + await expect(page.locator("#last-action")).toContainText("entered demo.base"); + await expect.poll(activeViewState).toBe("demo.base"); + await expect(page.getByText("Base state")).toBeVisible(); + + successStart = (await diagnostics()).succeeded.length; + + await page.getByRole("button", { name: "Error boundary" }).click(); + + await expect(page.locator("#current-state")).toContainText("demo.fallback"); + await expect(page.locator("#last-action")).toContainText( + "entered demo.fallback", + ); + const boundarySuccesses = (await diagnostics()).succeeded.slice(successStart); + expect(boundarySuccesses).toContain("demo.loading"); + expect(boundarySuccesses).toContain("demo.fallback"); + await expect.poll(activeViewState).toBe("demo.fallback"); + await expect( + page.getByText("Fallback recovery route reached."), + ).toBeVisible(); +}); + +test("route retention docs example restores and evicts retained views", async ({ + page, +}) => { + const diagnostics = async () => + page.evaluate(() => + JSON.parse(JSON.stringify((window as any).routingRetentionDiagnostics)), + ); + + await page.goto(RETENTION_TEST_URL); + + await expect( + page.getByRole("heading", { name: "Route retention demo" }), + ).toBeVisible(); + await expect(page.locator("#current-state")).toContainText("retention.tabA"); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:0 C:0"); + await expect.poll(diagnostics).toMatchObject({ + destroys: { a: 0, b: 0, c: 0 }, + schedulerRuns: { a: 0, b: 0, c: 0 }, + queuedWork: { a: 0, b: 0, c: 0 }, + }); + + await page.getByRole("button", { name: "Tab A count: 0" }).click(); + await expect( + page.getByRole("button", { name: "Tab A count: 1" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Tab B" }).click(); + await expect(page.locator("#current-state")).toContainText("retention.tabB"); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:1 C:0"); + await expect.poll(diagnostics).toMatchObject({ + schedulerRuns: { a: 0, b: 0, c: 0 }, + queuedWork: { a: 1, b: 0, c: 0 }, + }); + + await page.getByRole("button", { name: "Tab A" }).click(); + await expect( + page.getByRole("button", { name: "Tab A count: 1" }), + ).toBeVisible(); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:1 C:0"); + await expect(page.getByText("Resume events: 1")).toBeVisible(); + await expect.poll(diagnostics).toMatchObject({ + schedulerRuns: { a: 1, b: 0, c: 0 }, + queuedWork: { a: 0, b: 1, c: 0 }, + }); + + await page.getByRole("button", { name: "Tab C" }).click(); + await expect(page.locator("#current-state")).toContainText("retention.tabC"); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:1 C:1"); + await expect.poll(diagnostics).toMatchObject({ + schedulerRuns: { a: 1, b: 0, c: 0 }, + queuedWork: { a: 1, b: 1, c: 0 }, + }); + + await page.getByRole("button", { name: "Plain route" }).click(); + await expect(page.locator("#current-state")).toContainText("plain"); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:1 C:1"); + await expect.poll(diagnostics).toMatchObject({ + destroys: { a: 1, b: 1, c: 0 }, + schedulerRuns: { a: 1, b: 0, c: 0 }, + queuedWork: { a: 0, b: 0 }, + }); + expect((await diagnostics()).queuedWork.c).toBeGreaterThan(0); + + await page.getByRole("button", { name: "Tab B" }).click(); + await expect(page.locator("#current-state")).toContainText("retention.tabB"); + await expect( + page.getByRole("button", { name: "Tab B count: 0" }), + ).toBeVisible(); + await expect(page.locator("#compile-counts")).toHaveText("A:1 B:2 C:1"); + await expect.poll(diagnostics).toMatchObject({ + destroys: { a: 1, b: 1, c: 0 }, + queuedWork: { a: 0, b: 0, c: 0 }, + }); + expect((await diagnostics()).schedulerRuns.c).toBeGreaterThan(0); +}); + +test("router param type docs example encodes hrefs and decodes matched URLs", async ({ + page, +}) => { + await page.goto(PARAM_TYPES_TEST_URL); + + await expect( + page.getByRole("heading", { name: "Router param type demo" }), + ).toBeVisible(); + await expect(page.locator("#current-state")).toContainText("none"); + await expect(page.locator("#generated-href")).toContainText( + "#!/item/ALPHA-12", + ); + + await page.goto(`${PARAM_TYPES_TEST_URL}#!/item/ALPHA-12`); + + await expect(page.locator("#current-state")).toContainText("item"); + await expect(page.locator("#current-slug")).toContainText("alpha-12"); + await expect(page.locator("#decoded-route-slug")).toContainText("alpha-12"); + await expect(page).toHaveURL(/#!\/item\/ALPHA-12$/); +}); + +test("router state link docs example binds literal routes and params", async ({ + page, +}) => { + await page.goto(STATE_LINKS_TEST_URL); + + await expect( + page.getByRole("heading", { name: "Router state link demo" }), + ).toBeVisible(); + await expect(page.locator("#current-state")).toContainText("orders"); + await expect(page.locator("#orders-link")).toHaveAttribute( + "href", + "#!/orders", + ); + await expect(page.locator("#order-detail-link")).toHaveAttribute( + "href", + "#!/orders/42", + ); + await expect(page.locator("#orders-link")).toHaveAttribute( + "data-state-active", + "", + ); + await expect(page.locator("#orders-link")).toHaveAttribute( + "data-state-exact", + "", + ); + + await page.getByRole("link", { name: "Order 42" }).click(); + + await expect(page.locator("#current-state")).toContainText("orders.detail"); + await expect(page.locator("#current-order")).toContainText("42"); + await expect(page.locator("#order-detail-link")).toHaveAttribute( + "data-state-active", + "", + ); + await expect(page.locator("#order-detail-link")).toHaveAttribute( + "data-state-exact", + "", + ); + await expect(page.getByText("Order detail route loaded")).toBeVisible(); + await expect(page).toHaveURL(/#!\/orders\/42$/); +}); + +test("router scroll and focus docs example restores top and focuses route target", async ({ + page, +}) => { + await page.goto(SCROLL_FOCUS_TEST_URL); + + await expect( + page.getByRole("heading", { name: "Router scroll and focus demo" }), + ).toBeVisible(); + await expect(page.locator("#current-state")).toContainText("home"); + + await page.evaluate(() => window.scrollTo(0, 700)); + await expect + .poll(() => page.evaluate(() => window.scrollY)) + .toBeGreaterThan(0); + + await page.getByRole("button", { name: "Details" }).click(); + + await expect(page.locator("#current-state")).toContainText("details"); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await expect + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("details-title"); +}); diff --git a/src/router/services.spec.ts b/src/router/services.spec.ts index d1df9a9b3..d2b4e142a 100644 --- a/src/router/services.spec.ts +++ b/src/router/services.spec.ts @@ -4,71 +4,139 @@ import { dealoc } from "../shared/dom.ts"; import { waitUntil } from "../shared/test-utils.ts"; describe("router services", () => { - let providers: any; - let $injector: any; let $state: any; - let $$r: any; + let routerState: any; let $location: any; beforeEach(() => { dealoc(document.getElementById("app")); window.angular = new Angular(); - const module = window.angular.module("defaultModule", []); - - module.config( - ( - $stateRegistryProvider, - $$rProvider, - $transitionsProvider, - $stateProvider, - ) => { - providers = { - $stateRegistryProvider, - $$rProvider, - $transitionsProvider, - $stateProvider, - }; - }, - ); + const module = window.angular.module("defaultModule", []).router({ + name: "home", + url: "/startup-home", + template: "home", + }); $injector = window.angular.bootstrap(document.getElementById("app")!, [ "defaultModule", ]); $state = $injector.get("$state"); - $$r = $injector.get("$$r"); + routerState = $state._routerState; $location = $injector.get("$location"); }); - it("Should expose private ng-router providers through internal Angular DI", () => { - expect(providers.$stateRegistryProvider).toBeDefined(); - expect(providers.$$rProvider).toBeDefined(); - expect(providers.$transitionsProvider).toBeDefined(); - expect(providers.$stateProvider).toBeDefined(); - }); - - it("Should expose private ng-router services through internal Angular DI", () => { + it("exposes the public router services", () => { expect($injector.get("$stateRegistry")).toBeDefined(); - expect($injector.get("$$r")).toBeDefined(); expect($injector.get("$transitions")).toBeDefined(); expect($injector.get("$state")).toBeDefined(); - expect($injector.get("$view")).toBeDefined(); }); it("activates the initial url-matched state after sync", async () => { - providers.$stateProvider.state({ - name: "home", - url: "/startup-home", - template: "home", - }); - $location.url("/startup-home"); - $$r._sync(); + routerState._sync(); await waitUntil(() => $state.current.name === "home"); expect($state.current.name).toBe("home"); }); + + it("applies typed scroll and focus config through ngModule config", () => { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + + const configuredModule = window.angular.module("routerConfigModule", []); + configuredModule.config({ + $router: { + scroll: { top: 12, behavior: "auto" }, + focus: { selector: "[data-route-focus]", preventScroll: true }, + viewTransitions: false, + loading: "loadingState", + error: "errorState", + }, + }); + + const configuredInjector = window.angular.bootstrap( + document.getElementById("app")!, + ["routerConfigModule"], + ); + const router = (configuredInjector.get("$state") as any)._routerState; + + expect(router._scroll).toEqual({ top: 12, behavior: "auto" }); + expect(router._focus).toEqual({ + selector: "[data-route-focus]", + preventScroll: true, + }); + expect(router._viewTransitions).toBeFalse(); + expect(router._loading).toBe("loadingState"); + expect(router._error).toBe("errorState"); + }); + + describe("custom $router.paramTypes", () => { + let customStateModule: any; + let customStateModuleInjector: any; + let state: any; + let router: any; + let location: any; + + beforeEach(() => { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + + customStateModule = window.angular.module("customParamTypeModule", []); + customStateModule + .config({ + $router: { + paramTypes: { + slug: { + pattern: /s-[a-z]+-[0-9]+/i, + encode(value: string) { + return `S-${value}`; + }, + decode(value: string) { + return String(value).toLowerCase().replace(/^s-/, ""); + }, + is(value: unknown) { + return typeof value === "string" && value.includes("-"); + }, + equals(a: unknown, b: unknown) { + return a === b; + }, + }, + }, + }, + }) + .router({ + name: "item", + url: "/item/{slug:slug}", + template: "item", + }); + + customStateModule.config({ $location: { html5Mode: false } }); + + customStateModuleInjector = window.angular.bootstrap( + document.getElementById("app")!, + ["customParamTypeModule"], + ); + state = customStateModuleInjector.get("$state"); + router = state._routerState; + location = customStateModuleInjector.get("$location"); + }); + + it("reaches states and decodes params with a custom type", async () => { + location.url("/item/S-abc-12"); + router._sync(); + + await waitUntil(() => state.current.name === "item"); + + expect(state.current.name).toBe("item"); + expect(state.params.slug).toBe("abc-12"); + + await state.go("item", { slug: "def-34" }); + expect(location.url()).toContain("/item/S-def-34"); + expect(state.params.slug).toBe("def-34"); + }); + }); }); diff --git a/src/router/state/interface.ts b/src/router/state/interface.ts index 07dba2c1d..ed314da00 100644 --- a/src/router/state/interface.ts +++ b/src/router/state/interface.ts @@ -13,10 +13,392 @@ export type StateOrName = string | StateDeclaration | StateObject; export type StateStore = Record; +/** + * Public route contract entry used by router helper types. + * + * This is an author-written TypeScript shape. It is intentionally separate + * from built router state records so generated docs and language bindings do + * not expose internal state/runtime implementation details. + */ +export type RouteContract = { + params?: Record; + resolves?: Record; +}; + +/** + * Public route-name to route-contract map used by `StateService`, + * generic `Transition`, `ParamsOf`, and `ResolvesOf`. + */ +export type RouteMap = Record; + +type RouterResolvedRouteName< + TParentName extends string | undefined, + TName extends string, +> = TParentName extends string + ? TName extends `${string}.${string}` + ? TName + : `${TParentName}.${TName}` + : TName; + +type RouterResolveReturn = TResolve extends ( + ...args: never[] +) => infer TResult + ? Awaited + : TResolve extends readonly [...never[], infer TLast] + ? TLast extends (...args: never[]) => infer TResult + ? Awaited + : unknown + : TResolve extends { resolveFn: (...args: never[]) => infer TResult } + ? Awaited + : TResolve extends { data: infer TData } + ? TData + : unknown; + +type TrimLeft = TValue extends + | ` ${infer TRest}` + | `\n${infer TRest}` + | `\t${infer TRest}` + ? TrimLeft + : TValue; + +type TrimRight = TValue extends + | `${infer TRest} ` + | `${infer TRest}\n` + | `${infer TRest}\t` + ? TrimRight + : TValue; + +type Trim = TrimLeft>; + +type WidenLiteral = TValue extends string + ? string + : TValue extends number + ? number + : TValue extends boolean + ? boolean + : TValue; + +type RouterParamTypeDefinitionValue = TDefinition extends { + readonly decode: (...args: never[]) => infer TValue; +} + ? TValue + : TDefinition extends { + readonly is: ( + value: unknown, + ...args: never[] + ) => value is infer TValue; + } + ? TValue + : unknown; + +type RouterParamValueForType< + TType, + TParamTypes extends Record, +> = TType extends keyof TParamTypes + ? RouterParamTypeDefinitionValue + : TType extends string + ? Trim extends "int" + ? number + : Trim extends "bool" + ? boolean + : Trim extends "date" + ? Date + : Trim extends "json" | "any" + ? unknown + : Trim extends "string" | "path" | "query" | "hash" + ? string + : unknown + : unknown; + +type RouterParamArrayValue = TDeclaration extends { + readonly array: infer TArray; +} + ? TArray extends true + ? TValue[] + : TArray extends "auto" + ? TValue | TValue[] + : TValue + : TValue; + +type RouterParamDeclarationValue< + TDeclaration, + TParamTypes extends Record, +> = TDeclaration extends { + readonly type: infer TType; +} + ? RouterParamArrayValue< + RouterParamValueForType, + TDeclaration + > + : TDeclaration extends { readonly value: infer TValue } + ? RouterParamArrayValue, TDeclaration> + : WidenLiteral; + +type NormalizeUrlParamName = + Trim extends `${infer TArrayName}[]` ? TArrayName : Trim; + +type UrlParamRecord = { + [TKey in NormalizeUrlParamName]: TValue; +}; + +type RouterUrlBraceParam< + TBody extends string, + TDefaultType extends string, + TParamTypes extends Record, +> = TBody extends `${infer TName}:${infer TType}` + ? UrlParamRecord> + : UrlParamRecord>; + +type RouterUrlBraceParams< + TUrl extends string, + TDefaultType extends string, + TParamTypes extends Record, +> = TUrl extends `${string}{${infer TBody}}${infer TRest}` + ? RouterUrlBraceParam & + RouterUrlBraceParams + : object; + +type ReadColonParamName< + TValue extends string, + TName extends string = "", +> = TValue extends `${infer TChar}${infer TRest}` + ? TChar extends "/" | "?" | "&" | "#" + ? TName + : ReadColonParamName + : TName; + +type RouterUrlColonParams< + TUrl extends string, + TParamTypes extends Record, +> = TUrl extends `${string}:${infer TRest}` + ? UrlParamRecord< + ReadColonParamName, + RouterParamValueForType<"path", TParamTypes> + > & + (TRest extends `${ReadColonParamName}${infer TNext}` + ? RouterUrlColonParams + : object) + : object; + +type RouterUrlPath = + TUrl extends `${infer TPath}?${string}` ? TPath : TUrl; + +type RouterUrlPathWithoutBraceParams = + TUrl extends `${infer TBefore}{${string}}${infer TAfter}` + ? RouterUrlPathWithoutBraceParams<`${TBefore}${TAfter}`> + : TUrl; + +type RouterUrlQuery = + TUrl extends `${string}?${infer TQuery}` ? TQuery : ""; + +type RouterUrlQuerySegmentParam< + TSegment extends string, + TParamTypes extends Record, +> = TSegment extends `{${infer TBody}}` + ? RouterUrlBraceParam + : TSegment extends "" + ? object + : UrlParamRecord>; + +type RouterUrlQueryParams< + TQuery extends string, + TParamTypes extends Record, +> = TQuery extends `${infer TSegment}&${infer TRest}` + ? RouterUrlQuerySegmentParam & + RouterUrlQueryParams + : RouterUrlQuerySegmentParam; + +type Simplify = { [TKey in keyof TValue]: TValue[TKey] }; + +type MergeRouteParams = Simplify< + Omit & TExplicitParams +>; + +type RouterTreeUrlParams< + TTree, + TParamTypes extends Record, +> = TTree extends { readonly url: infer TUrl } + ? TUrl extends string + ? Simplify< + RouterUrlBraceParams, "path", TParamTypes> & + RouterUrlColonParams< + RouterUrlPathWithoutBraceParams>, + TParamTypes + > & + Partial, TParamTypes>> + > + : Record + : Record; + +type RouterTreeExplicitParams< + TTree, + TParamTypes extends Record, +> = TTree extends { + readonly params: infer TParams; +} + ? TParams extends Record + ? { + [TKey in Extract]: RouterParamDeclarationValue< + TParams[TKey], + TParamTypes + >; + } + : Record + : Record; + +type RouterTreeParams< + TTree, + TParamTypes extends Record, +> = TTree extends { readonly params: infer TParams } + ? TParams extends Record + ? MergeRouteParams< + RouterTreeUrlParams, + RouterTreeExplicitParams + > + : RouterTreeUrlParams + : RouterTreeUrlParams; + +type RouterTreeResolves = TTree extends { + readonly resolve: infer TResolve; +} + ? TResolve extends readonly unknown[] + ? Record + : TResolve extends Record + ? { + [TKey in Extract]: RouterResolveReturn< + TResolve[TKey] + >; + } + : Record + : Record; + +type RouterTreeRouteEntry< + TTree, + TParentName extends string | undefined, + TParamTypes extends Record, +> = TTree extends { readonly name: infer TName extends string } + ? { + [TResolvedName in RouterResolvedRouteName]: { + params: RouterTreeParams; + resolves: RouterTreeResolves; + }; + } + : object; + +type UnionToIntersection = ( + TUnion extends unknown ? (value: TUnion) => void : never +) extends (value: infer TIntersection) => void + ? TIntersection + : never; + +type RouterTreeChildrenRouteMap< + TChildren, + TParentName extends string, + TParamTypes extends Record, +> = [TChildren] extends [never] + ? object + : TChildren extends readonly [infer TFirstChild, ...infer TRestChildren] + ? RouterTreeRouteMap & + RouterTreeChildrenRouteMap + : TChildren extends readonly (infer TChild)[] + ? UnionToIntersection< + RouterTreeRouteMap + > + : object; + +type RouterTreeChildren = TTree extends { readonly children?: unknown } + ? NonNullable + : never; + +/** @inline */ +type RouterTreeRouteMap< + TTree, + TParentName extends string | undefined, + TParamTypes extends Record, +> = TTree extends { readonly name: infer TName extends string } + ? RouterTreeRouteEntry & + RouterTreeChildrenRouteMap< + RouterTreeChildren, + RouterResolvedRouteName, + TParamTypes + > + : object; + +/** + * Derives a public route map from a literal `router(...)` tree. + * + * The helper keeps route typing tied to the same declaration object passed to + * [[NgModule.router]] without exposing built router state, transition, or view + * records. It infers dot-composed child route names, explicitly declared + * `params` keys, built-in URL param types, custom `$router.paramTypes` values, + * and object-style `resolve` return values. + * + */ +type RouterTreeShape = { name: string; children?: readonly unknown[] }; + +type RouteMapFromTree< + TTree extends RouterTreeShape, + TParamTypes extends Record = Record, +> = + RouterTreeRouteMap extends infer TRouteMap + ? { [TKey in Extract]: TRouteMap[TKey] } + : never; + +type RouteMapFromForest< + TTrees extends readonly RouterTreeShape[], + TParamTypes extends Record, +> = TTrees extends readonly [ + infer TFirst extends RouterTreeShape, + ...infer TRest extends readonly RouterTreeShape[], +] + ? RouteMapFromTree & + RouteMapFromForest + : TTrees extends readonly (infer TTree extends RouterTreeShape)[] + ? UnionToIntersection> + : object; + +/** + * Derives the public route map for a literal `router(...)` tree. + */ +export type RoutesOf< + TTree extends RouterTreeShape | readonly RouterTreeShape[], + TParamTypes extends Record = Record, +> = TTree extends readonly RouterTreeShape[] + ? RouteMapFromForest extends infer TRouteMap + ? { [TKey in Extract]: TRouteMap[TKey] } + : never + : TTree extends RouterTreeShape + ? RouteMapFromTree + : never; + +/** + * Params declared by one route in a public route map. + */ +export type ParamsOf< + TRouteMap extends RouteMap, + TRouteName extends Extract, +> = TRouteMap[TRouteName] extends { readonly params?: infer TParams } + ? TParams extends Record + ? TParams + : Record + : Record; + +/** + * Resolve values declared by one route in a public route map. + */ +export type ResolvesOf< + TRouteMap extends RouteMap, + TRouteName extends Extract, +> = TRouteMap[TRouteName] extends { readonly resolves?: infer TResolves } + ? TResolves extends Record + ? TResolves + : Record + : Record; + export type StateTransitionResult = StateDeclaration | undefined; export interface TransitionPromise extends Promise { - transition: Transition; + readonly transition?: Transition; } export type LazyStateLoadResult = @@ -120,7 +502,7 @@ export interface ViewDeclarationCommon { * * #### Example: * ```js - * $stateProvider.state('foo', { + * app.router('foo', { * resolve: { * foo: function(FooService) { return FooService.get(); }, * bar: function(BarService) { return BarService.get(); } @@ -252,6 +634,177 @@ export type RedirectToResult = | { state?: string; params?: RawParams } | undefined; +export interface StateNavigationPolicyDeclaration { + require?: string | string[]; + permissions?: string | string[]; + redirectTo?: string; + public?: boolean; + reason?: string; +} + +export interface StateTransitionPolicyContext { + operation: "canExit" | "dirty"; + transition: Transition; + from: StateDeclaration; + to: StateDeclaration; + state: StateDeclaration; +} + +export interface StateTransitionErrorPolicyContext { + operation: "error"; + transition?: Transition; + from: StateDeclaration; + to: StateDeclaration; + state: StateDeclaration; + error: unknown; +} + +export interface StateTransitionLoadingPolicyContext { + operation: "loading"; + transition: Transition; + from: StateDeclaration; + to: StateDeclaration; + state: StateDeclaration; +} + +export interface StateTransitionRetryPolicyContext { + operation: "retry"; + transition?: Transition; + from: StateDeclaration; + to: StateDeclaration; + state: StateDeclaration; + attempt: number; + error?: unknown; +} + +export type StateCanExitPolicy = Injectable< + (context: StateTransitionPolicyContext) => boolean | RedirectToResult +>; + +export type StateDirtyPolicy = Injectable< + (context: StateTransitionPolicyContext) => boolean +>; + +export type StateRetryPolicy = Injectable< + (context: StateTransitionRetryPolicyContext) => boolean | number +>; + +export type StateErrorBoundaryPolicy = Injectable< + (context: StateTransitionErrorPolicyContext) => RedirectToResult +>; + +export type StateTransitionLoadingPolicy = Injectable< + (context: StateTransitionLoadingPolicyContext) => boolean | RedirectToResult +>; + +export interface StateDirtyPolicyDeclaration { + when: StateDirtyPolicy; + prompt?: string; + redirectTo?: string; +} + +export interface StateTransitionPolicyDeclaration { + canExit?: StateCanExitPolicy; + dirty?: StateDirtyPolicyDeclaration; + retry?: boolean | number | StateRetryPolicy; + fallbackTo?: StateTransitionFallbackPolicy; + loading?: boolean | string | StateTransitionLoadingPolicy; + errorBoundary?: + | string + | { state?: string; params?: RawParams } + | RedirectToResult + | StateErrorBoundaryPolicy; + error?: + | string + | { state?: string; params?: RawParams } + | RedirectToResult + | StateErrorBoundaryPolicy; +} + +export type StateTransitionFallbackPolicy = + | string + | { + state?: string; + params?: RawParams; + }; + +export type StateRetentionMode = "destroy" | "keep-alive"; + +export type StateRetentionPauseMode = "none" | "background" | "schedulers"; + +export type StateRetentionEvictionMode = "lru" | "oldest"; + +export interface StateRetentionPolicyContext { + transition: Transition; + state: StateDeclaration; + params: RawParams; +} + +export interface StateRetentionEvictionContext { + state: StateDeclaration; + key: string; + size: number; + max: number; +} + +export type StateRetentionKeyPolicy = Injectable< + (context: StateRetentionPolicyContext) => string +>; + +export type StateRetentionEvictionPolicy = Injectable< + (context: StateRetentionEvictionContext) => string | undefined +>; + +export interface StateRetentionPolicyDeclaration { + /** + * Controls whether this route subtree is destroyed on exit or deactivated + * and retained for later reactivation. + */ + mode?: StateRetentionMode; + + /** + * Optional cache key override. By default, retained route instances are keyed + * by state identity and params. + */ + key?: string | StateRetentionKeyPolicy; + + /** + * Maximum number of retained route instances allowed for this policy. + */ + max?: number; + + /** + * Controls how inactive retained route trees pause work while deactivated. + */ + pause?: StateRetentionPauseMode; + + /** + * Eviction strategy used when the retained route cache exceeds `max`. + */ + evict?: StateRetentionEvictionMode | StateRetentionEvictionPolicy; +} + +export interface StatePolicyDeclaration { + navigation?: StateNavigationPolicyDeclaration; + transition?: StateTransitionPolicyDeclaration; + retention?: StateRetentionPolicyDeclaration; +} + +/** + * Module-owned router state tree declaration. + * + * Use this with [[NgModule.router]] when a module owns a route subtree. Child + * state names are relative to their parent unless they contain a dot. + */ +export interface RouterModuleDeclaration extends StateDeclaration { + /** + * Child states owned by this module route tree. + * + * Each child is lowered to a normal [[StateDeclaration]] before registration. + */ + children?: readonly RouterModuleDeclaration[]; +} + /** * The StateDeclaration object is used to define a state or nested state. * @@ -299,8 +852,8 @@ export interface StateDeclaration extends ViewDeclarationCommon { * Normally, a state's parent is implied from the state's [[name]], e.g., `"parentstate.childstate"`. * * Alternatively, you can explicitly set the parent state using this property. - * This allows shorter state names, e.g., `Child` - * instead of `Child + * This allows shorter state names, e.g., `Child` + * instead of `Child * * When using this property, the state's name should not have any dots in it. * @@ -319,16 +872,6 @@ export interface StateDeclaration extends ViewDeclarationCommon { */ parent?: string | StateDeclaration; - /** - * Gets the internal State object API - * - * Gets the *internal API* for a registered state. - * - * Note: the internal [[StateObject]] API is subject to change without notice - * @internal - */ - _state?: () => BuiltStateDeclaration; - /** * Named view declarations for this state. * @@ -517,7 +1060,8 @@ export interface StateDeclaration extends ViewDeclarationCommon { * This is a spot for you to store inherited state metadata. * Child states' `data` object will prototypally inherit from their parent state. * - * This is a good spot to put metadata such as `requiresAuth`. + * Use this for application metadata. Use `policy.navigation` for framework + * navigation decisions such as authentication, permissions, or redirects. * * Note: because prototypal inheritance is used, changes to parent `data` objects reflect in the child `data` objects. * Care should be taken if you are using `hasOwnProperty` on the `data` object. @@ -525,6 +1069,17 @@ export interface StateDeclaration extends ViewDeclarationCommon { */ data?: unknown; + /** + * Declarative state policy metadata consumed by AngularTS framework services. + * + * `policy.navigation` is inherited through the state tree and evaluated by + * the router's security navigation hook before resolves, controllers, or + * views run. `policy.transition.canExit` is evaluated before exiting states + * are torn down. `policy.retention` declares keep-alive route subtree + * behavior and can override router-wide retention defaults. + */ + policy?: StatePolicyDeclaration; + /** * Synchronously or asynchronously redirects Transitions to a different state/params * @@ -603,7 +1158,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * ### Example: * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onEnter: (MyService, $transition$, $state$) => { * return MyService.doSomething($state$.name, $transition$.params()); @@ -613,7 +1168,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * #### Example:` * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onEnter: [ 'MyService', '$transition$', '$state$', function (MyService, $transition$, $state$) { * return MyService.doSomething($state$.name, $transition$.params()); @@ -630,7 +1185,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * #### Example: * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onRetain: (MyService, $transition$, $state$) => { * return MyService.doSomething($state$.name, $transition$.params()); @@ -640,7 +1195,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * #### Example:` * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onRetain: [ 'MyService', '$transition$', '$state$', function (MyService, $transition$, $state$) { * return MyService.doSomething($state$.name, $transition$.params()); @@ -657,7 +1212,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * ### Example: * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onExit: (MyService, $transition$, $state$) => { * return MyService.doSomething($state$.name, $transition$.params()); @@ -667,7 +1222,7 @@ export interface StateDeclaration extends ViewDeclarationCommon { * * #### Example:` * ```js - * $stateProvider.state({ + * app.router({ * name: 'mystate', * onExit: [ 'MyService', '$transition$', '$state$', function (MyService, $transition$, $state$) { * return MyService.doSomething($state$.name, $transition$.params()); @@ -688,10 +1243,15 @@ export interface StateDeclaration extends ViewDeclarationCommon { dynamic?: boolean; } +/** Internal router augmentation added after a state declaration is registered. */ +export interface InternalStateDeclaration extends StateDeclaration { + _state: () => BuiltStateDeclaration; +} + /** * Represents a fully built StateObject after registration in the StateRegistry. */ -export type BuiltStateDeclaration = StateDeclaration & { +export type BuiltStateDeclaration = InternalStateDeclaration & { /** Reference to the original StateDeclaration */ self: StateDeclaration; @@ -711,7 +1271,7 @@ export type BuiltStateDeclaration = StateDeclaration & { /** Full path from root down to this state */ path: BuiltStateDeclaration[]; - /** Fast lookup of included states for $state.includes() */ + /** Fast lookup of ancestor states for `$state.matches()`. */ includes: Record; /** Closest ancestor state that has a URL (navigable) */ diff --git a/src/router/state/state-builder.spec.ts b/src/router/state/state-builder.spec.ts index 3aff40895..4f7728ece 100644 --- a/src/router/state/state-builder.spec.ts +++ b/src/router/state/state-builder.spec.ts @@ -2,7 +2,8 @@ /// import { StateBuilder } from "./state-builder.ts"; import { StateObject } from "./state-object.ts"; -import { getLocals } from "./state-registry.ts"; +import { createResolveInvocationLocals } from "../resolve/resolve-context.ts"; +import type { ResolvableToken } from "../resolve/interface.ts"; import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; @@ -29,6 +30,7 @@ describe("StateBuilder", function () { it("expect it to be configured by state registry", () => { expect($stateRegistry).toBeDefined(); expect($stateRegistry._builder).toBeDefined(); + expect($stateRegistry.root().name).toBe(""); }); it("should build a single default view from state-level view properties", function () { @@ -286,7 +288,7 @@ describe("StateBuilder", function () { }); }); - describe("StateRegistryProvider", () => { + describe("StateRegistryRuntime", () => { it("queues child states until their parent is registered", () => { const events = []; @@ -294,7 +296,7 @@ describe("StateBuilder", function () { events.push({ event, names: states.map((state) => state.name) }); }); - const child = $stateRegistry.register({ + const child = $stateRegistry._register({ name: "queued.child", template: "child", }); @@ -354,19 +356,30 @@ describe("StateBuilder", function () { $stateRegistry.get().some((state) => state.name === "listMe"), ).toBe(true); }); + + it("returns all declarations from getAll", () => { + $stateRegistry.register({ name: "listAll", template: "list" }); + + expect( + $stateRegistry.getAll().some((state) => state.name === "listAll"), + ).toBe(true); + }); }); - describe("getLocals", () => { + describe("createResolveInvocationLocals", () => { it("returns locals for string tokens only", () => { const symbolToken = Symbol("ignored"); const ctx = { getTokens: () => ["user", symbolToken, "count"], - getResolvable: (token) => ({ + getResolvable: (token: ResolvableToken) => ({ data: token === "user" ? "Ada" : 2, }), }; - expect(getLocals(ctx)).toEqual({ user: "Ada", count: 2 }); + expect(createResolveInvocationLocals(ctx)).toEqual({ + user: "Ada", + count: 2, + }); }); }); }); diff --git a/src/router/state/state-builder.ts b/src/router/state/state-builder.ts index f3cd89040..9f8792116 100644 --- a/src/router/state/state-builder.ts +++ b/src/router/state/state-builder.ts @@ -11,15 +11,20 @@ import { assertDefined, } from "../../shared/utils.ts"; import { stringify } from "../../shared/strings.ts"; -import { ResolveContext } from "../resolve/resolve-context.ts"; +import { + createResolveInvocationLocals, + ResolveContext, + type ResolveInvocationLocals, +} from "../resolve/resolve-context.ts"; import { Resolvable } from "../resolve/resolvable.ts"; import { annotate } from "../../core/di/di.ts"; import { normalizeNgViewTarget } from "./view-target.ts"; -import type { CompileProvider } from "../../core/compile/compile.ts"; +import type { CompileRegistry } from "../../core/compile/compile.ts"; import type { ParamFactory } from "../params/param-factory.ts"; import type { ResolveFn, ResolvableLiteral } from "../resolve/interface.ts"; import type { BuiltStateDeclaration, + InternalStateDeclaration, RouterInjectable, StateDeclaration, ViewDeclaration, @@ -28,7 +33,7 @@ import { DefType, type Param } from "../params/param.ts"; import type { StateMatcher } from "./state-matcher.ts"; import type { StateObject } from "./state-object.ts"; import { UrlMatcher } from "../url/url-matcher.ts"; -import type { RouterProvider } from "../router.ts"; +import type { RouterRuntimeState } from "../router.ts"; import type { HookResult, TransitionStateHookFn, @@ -46,6 +51,11 @@ interface StateLifecycleHookContext { _$injector: ng.InjectorService | undefined; } +interface StateLifecycleInvocationLocals extends ResolveInvocationLocals { + $state$: StateDeclaration; + $transition$: ng.Transition; +} + interface RouteComponentRegistrar { register(viewName: string, component: ng.Component): string; } @@ -74,7 +84,7 @@ function parseUrl(url: unknown): false | { val: string; root: boolean } { function buildUrl( stateObject: StateObject & BuiltStateDeclaration, - routerState: RouterProvider, + routerState: RouterRuntimeState, root: StateObject | BuiltStateDeclaration, ): UrlMatcher | null { const stateDec = stateObject.self; @@ -297,20 +307,6 @@ function viewsBuilder( return views; } -function getResolveLocals(ctx: ResolveContext): Record { - const tokens = ctx.getTokens(); - - const locals: Record = {}; - - tokens.forEach((key) => { - if (isString(key)) { - locals[key] = assertDefined(ctx.getResolvable(key)).data; - } - }); - - return locals; -} - function valueToResolvable( token: string, value: unknown, @@ -415,7 +411,9 @@ function invokeStateLifecycleHook( hookName: StateLifecycleHookName, pathname: StateLifecyclePathName, ): HookResult { - const stateObject = (state as BuiltStateDeclaration)._state() as StateObject; + const stateObject = ( + state as InternalStateDeclaration + )._state() as StateObject; const hook = stateObject[hookName]; @@ -432,12 +430,13 @@ function invokeStateLifecycleHook( const subContext = resolveContext.subContext(stateObject); - const locals = assign(getResolveLocals(subContext), { + const locals: StateLifecycleInvocationLocals = { + ...createResolveInvocationLocals(subContext), $state$: state, $transition$: trans, - }); + }; - return $injector.invoke(hook, hookContext, locals); + return $injector.invoke(hook, hookContext, locals) as HookResult; } function invokeOnEnterHook( @@ -479,26 +478,26 @@ export class StateBuilder { /** @internal */ _paramFactory: ParamFactory; /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; /** @internal */ - _compileProvider: CompileProvider | undefined; + _compileRegistry: CompileRegistry | undefined; /** @internal */ _registeredRouteComponents: Set; /** * @param {StateMatcher} matcher - * @param {RouterProvider} routerState + * @param {RouterRuntimeState} routerState */ constructor( matcher: StateMatcher, - routerState: RouterProvider, - compileProvider?: CompileProvider, + routerState: RouterRuntimeState, + compileRegistry?: CompileRegistry, ) { this._matcher = matcher; this._$injector = undefined; this._paramFactory = routerState._paramFactory; this._routerState = routerState; - this._compileProvider = compileProvider; + this._compileRegistry = compileRegistry; this._registeredRouteComponents = new Set(); } @@ -511,13 +510,13 @@ export class StateBuilder { const name = routeComponentName(stateName, viewName); if (!this._registeredRouteComponents.has(name)) { - if (!this._compileProvider) { + if (!this._compileRegistry) { throw new Error( - `State '${stateName}' cannot register inline component '${viewName}' before the compile provider is available`, + `State '${stateName}' cannot register inline component '${viewName}' before the compile registry is available`, ); } - this._compileProvider.component( + this._compileRegistry.component( name, assign({}, component, { replace: true }), ); diff --git a/src/router/state/state-declaration-types.spec.ts b/src/router/state/state-declaration-types.spec.ts new file mode 100644 index 000000000..0b1bef86f --- /dev/null +++ b/src/router/state/state-declaration-types.spec.ts @@ -0,0 +1,776 @@ +import type { + BuiltStateDeclaration, + InternalStateDeclaration, + ParamsOf, + ResolvesOf, + RoutesOf, + RouterModuleDeclaration, + StateDeclaration, + RouteMap, + StateTransitionRetryPolicyContext, + StateRetentionPolicyContext, + StateRetryPolicy, + StateTransitionPolicyContext, + StateTransitionErrorPolicyContext, + StateTransitionLoadingPolicyContext, +} from "./interface.ts"; +import type { StateRegistryRuntime } from "./state-registry.ts"; +import type { StateService } from "./state-service.ts"; +import type { Transition } from "../transition/transition.ts"; +import type { RouterConfig } from "../router.ts"; + +type AppRouteMap = { + "admin.users": { + params: { + page?: number; + query?: string; + }; + resolves: { + users: { id: string }[]; + total: number; + }; + }; + "admin.roles": { + resolves: { + roles: { id: number; name: string }[]; + }; + }; + "admin.profile": { + params: { + userId: string; + }; + }; + "admin.dashboard": {}; +}; + +const userParams: ParamsOf = { + page: 1, + query: "active", +}; + +const aliasUserParams: ParamsOf = { + page: 1, + query: "active", +}; + +const profileParams: ParamsOf = { + userId: "42", +}; + +const invalidUserParams: ParamsOf = { + // @ts-expect-error route params reject unknown keys + userId: "42", +}; + +// @ts-expect-error required params stay required for route maps +const missingProfileParams: ParamsOf = {}; + +const roleResolves: ResolvesOf = { + roles: [{ id: 1, name: "admin" }], +}; + +const userResolves: ResolvesOf = { + users: [{ id: "1" }], + total: 1, +}; + +const aliasUserResolves: ResolvesOf = { + users: [{ id: "1" }], + total: 1, +}; + +// @ts-expect-error route resolves reject missing required resolve keys +const missingUserResolves: ResolvesOf = { + users: [{ id: "1" }], +}; + +declare const stateService: StateService; + +stateService.go("admin.users", userParams); +stateService.go("admin.users"); +stateService.go("admin.roles"); +stateService.go("admin.profile", profileParams); +stateService.go("admin.users", { page: 2 }); +stateService.go("admin.roles"); +stateService.href("admin.users", { query: "all" }); +stateService.href("admin.profile", profileParams); + +// @ts-expect-error unknown route names are rejected +stateService.go("admin.unknown", {}); + +// @ts-expect-error href rejects unknown typed route names +stateService.href("admin.unknown", {}); + +// @ts-expect-error params must match map for route +stateService.go("admin.profile", { page: 1 }); + +// @ts-expect-error href params must match map for route +stateService.href("admin.profile", { page: 1 }); + +// @ts-expect-error params must be route-specific and avoid unknown keys +stateService.go("admin.roles", { userId: "42" }); + +// @ts-expect-error href params are route-specific and avoid unknown keys +stateService.href("admin.roles", { userId: "42" }); + +// @ts-expect-error missing required params are rejected +stateService.go("admin.profile", {}); + +// @ts-expect-error required params cannot be omitted +stateService.go("admin.profile"); + +// @ts-expect-error go requires required params +stateService.go("admin.profile"); + +// @ts-expect-error href rejects missing required params +stateService.href("admin.profile", {}); + +// @ts-expect-error href requires required params +stateService.href("admin.profile"); + +const invalidResolves: ResolvesOf = { + // @ts-expect-error resolve map keys are strictly inferred + invalidRole: { name: "x" }, +}; + +type StaticNgStateLiteral< + TRouteMap extends RouteMap, + TName extends Extract, +> = { + state: TName; + params: ParamsOf; +}; + +const staticNgStateProfileLink: StaticNgStateLiteral< + AppRouteMap, + "admin.profile" +> = { + state: "admin.profile", + params: { userId: "42" }, +}; + +void staticNgStateProfileLink; + +const invalidStaticNgStateProfileLink: StaticNgStateLiteral< + AppRouteMap, + "admin.profile" +> = { + state: "admin.profile", + // @ts-expect-error quoted literal ng-state examples can mirror typed route params + params: { page: 1 }, +}; + +const typedStateByName = stateService.get("admin.users"); + +declare const $transition$: Transition< + AppRouteMap, + "admin.profile", + "admin.users" +>; + +const transitionTargetParams = $transition$.params(); +const transitionExplicitTargetParams = $transition$.params("to"); +const transitionSourceParams = $transition$.params("from"); +const transitionRetainedParams = $transition$.params("retained"); + +transitionTargetParams.userId.toUpperCase(); +transitionExplicitTargetParams.userId.toUpperCase(); +transitionSourceParams.page?.toFixed(); +transitionSourceParams.query?.toUpperCase(); +void transitionRetainedParams["anyRuntimeParam"]; + +// @ts-expect-error target params reject source-only keys +transitionTargetParams.page; + +// @ts-expect-error explicit target params reject source-only keys +transitionExplicitTargetParams.query; + +// @ts-expect-error source params reject target-only keys +transitionSourceParams.userId; + +declare const untypedTransition: Transition; +const untypedTransitionParams = untypedTransition.params(); +void untypedTransitionParams["runtimeParam"]; + +type AssertStateGetReturn = ReturnType["get"]>; +type AssertStateGetHasPrivateMarker = Extract< + AssertStateGetReturn, + { _state(): unknown } +>; +type AssertStateGetIsPublicOnly = AssertStateGetHasPrivateMarker extends never + ? true + : false; +type AssertLiteral = T; + +const _typedStateGetExcludesPrivateMarker: AssertLiteral = true; + +const declaration: StateDeclaration = { + name: "home", + url: "/home", +}; + +void declaration; + +const retainedDeclaration: StateDeclaration = { + name: "workspace.editor", + url: "/editor/:id", + policy: { + retention: { + mode: "keep-alive", + key: [ + "retentionContext", + (context: StateRetentionPolicyContext) => + `editor:${String(context.params.id ?? "new")}`, + ], + max: 5, + pause: "background", + evict: "lru", + }, + }, +}; + +const retainedRouterTree: RouterModuleDeclaration = { + name: "workspace", + url: "/workspace", + policy: { + retention: { + mode: "keep-alive", + key: (context: StateRetentionPolicyContext) => + `${context.state.name}:${String(context.params.id ?? "index")}`, + max: 3, + pause: "schedulers", + evict: () => undefined, + }, + }, + children: [ + { + name: "editor", + url: "/editor/:id", + }, + ], +}; + +const inferredRouterTree = { + name: "admin", + url: "/admin/{section:string}", + params: { + section: { value: "users" }, + }, + resolve: { + account: () => Promise.resolve({ id: "admin" }), + }, + children: [ + { + name: "users", + url: "/users/{page:int}?{query:string}&{active:bool}", + params: { + page: { type: "int" }, + }, + resolve: { + users: () => [{ id: "1" }], + total: () => Promise.resolve(1), + }, + }, + { + name: "admin.profile", + url: "/profile/:userId?{viewedAt:date}", + params: { + userId: { type: "string" }, + }, + resolve: { + profile: () => ({ id: "42" }), + }, + }, + ], +} as const; + +const inferredRouterTreeDeclaration: RouterModuleDeclaration = + inferredRouterTree; + +type InferredRouterTreeRoutes = RoutesOf; +type InferredRouterTreeRoutesAlias = RoutesOf; +type NamespaceInferredRouterTreeRoutesAlias = ng.RoutesOf< + typeof inferredRouterTree +>; +type NamespaceInferredRouterTreeRoutes = NamespaceInferredRouterTreeRoutesAlias; + +type UuidParam = string & { readonly __uuid: unique symbol }; +type SlugParam = string & { readonly __slug: unique symbol }; + +const typedRouteParamTypes = { + uuid: { + pattern: /[0-9a-f-]{36}/i, + encode(value: UuidParam) { + return value; + }, + decode(value: string): UuidParam { + return value as UuidParam; + }, + is(value: unknown): value is UuidParam { + return typeof value === "string"; + }, + }, + slug: { + pattern: /[a-z0-9-]+/i, + encode(value: SlugParam) { + return value; + }, + is(value: unknown): value is SlugParam { + return typeof value === "string"; + }, + }, +} as const; + +const typedRouteParamTypesConfig: RouterConfig = { + paramTypes: typedRouteParamTypes, + viewTransitions: true, + loading: "loading", + retry: 2, + fallbackTo: "fallback", + errorBoundary: "error", + error: { state: "error", params: {} }, +}; + +void typedRouteParamTypesConfig; + +const customParamRouterTree = { + name: "catalog", + url: "/catalog/{itemId:uuid}?{filter:slug}", + params: { + related: { type: "uuid", array: true }, + fallback: { type: "slug" }, + }, +} as const; + +type CustomParamRouterTreeRoutes = RoutesOf< + typeof customParamRouterTree, + typeof typedRouteParamTypes +>; + +declare const inferredStateService: StateService; +declare const inferredAliasStateService: StateService; +declare const customParamStateService: StateService; + +inferredStateService.go("admin", { section: "settings" }); +inferredAliasStateService.go("admin.users", { page: 1 }); +inferredStateService.go("admin.users", { page: 1 }); +inferredStateService.go("admin.users", { + page: 1, + query: "active", + active: true, +}); +inferredStateService.href("admin.profile", { userId: "42" }); +inferredStateService.href("admin.profile", { + userId: "42", + viewedAt: new Date(), +}); +customParamStateService.go("catalog", { + itemId: "b4f6eb70-3d2b-42df-8f14-4c7e8e88c208" as UuidParam, + filter: "active-items" as SlugParam, + related: ["a2dc428b-2f79-4ac0-8d2f-8c521765b683" as UuidParam], + fallback: "default-items" as SlugParam, +}); + +// @ts-expect-error int URL params are inferred as numbers +inferredStateService.go("admin.users", { page: "1" }); + +// @ts-expect-error bool URL params are inferred as booleans +inferredStateService.go("admin.users", { page: 1, active: "true" }); + +// @ts-expect-error date URL params are inferred as Date values +inferredStateService.href("admin.profile", { + userId: "42", + viewedAt: "2026-06-24", +}); + +// @ts-expect-error relative child route names are dot-composed from their parent +inferredStateService.go("users", { page: 1 }); + +// @ts-expect-error explicit params declared on the router tree are required +inferredStateService.go("admin.users", {}); + +// @ts-expect-error custom URL param types infer from $router.paramTypes decode +customParamStateService.go("catalog", { + itemId: "b4f6eb70-3d2b-42df-8f14-4c7e8e88c208", + related: ["a2dc428b-2f79-4ac0-8d2f-8c521765b683" as UuidParam], + fallback: "default-items" as SlugParam, +}); + +// @ts-expect-error custom query param types infer from $router.paramTypes is predicate +customParamStateService.go("catalog", { + itemId: "b4f6eb70-3d2b-42df-8f14-4c7e8e88c208" as UuidParam, + filter: "active-items", + related: ["a2dc428b-2f79-4ac0-8d2f-8c521765b683" as UuidParam], + fallback: "default-items" as SlugParam, +}); + +// @ts-expect-error explicit params using custom array types require arrays +customParamStateService.go("catalog", { + itemId: "b4f6eb70-3d2b-42df-8f14-4c7e8e88c208" as UuidParam, + related: "a2dc428b-2f79-4ac0-8d2f-8c521765b683" as UuidParam, + fallback: "default-items" as SlugParam, +}); + +const inferredUserResolves: ResolvesOf< + InferredRouterTreeRoutes, + "admin.users" +> = { + users: [{ id: "1" }], + total: 1, +}; + +const namespaceProfileParams: ng.ParamsOf< + NamespaceInferredRouterTreeRoutes, + "admin.profile" +> = { + userId: "42", +}; + +const namespaceAliasProfileParams: ng.ParamsOf< + NamespaceInferredRouterTreeRoutesAlias, + "admin.profile" +> = { + userId: "42", +}; + +const namespaceProfileResolves: ng.ResolvesOf< + NamespaceInferredRouterTreeRoutes, + "admin.profile" +> = { + profile: { id: "42" }, +}; + +const namespaceAliasProfileResolves: ng.ResolvesOf< + NamespaceInferredRouterTreeRoutesAlias, + "admin.profile" +> = { + profile: { id: "42" }, +}; + +// @ts-expect-error object-style resolve return types are inferred from the tree +const invalidInferredUserResolves: ResolvesOf< + InferredRouterTreeRoutes, + "admin.users" +> = { + users: [{ id: "1" }], +}; + +const transitionPolicyDeclaration: StateDeclaration = { + name: "workflow", + url: "/workflow", + policy: { + transition: { + canExit: (context: StateTransitionPolicyContext) => { + context.operation; + context.from; + context.to; + context.state; + context.transition; + + return { state: "workflow.confirm", params: { step: "1" } }; + }, + }, + }, +}; + +const invalidTransitionPolicy: StateDeclaration = { + name: "invalid.canExit", + policy: { + transition: { + // @ts-expect-error canExit must return boolean or redirect shape + canExit: (context: StateTransitionPolicyContext) => context, + dirty: { + // @ts-expect-error dirty.when must evaluate a boolean predicate + when: (context: StateTransitionPolicyContext) => context, + }, + }, + }, +}; + +const dirtyTransitionPolicyDeclaration: StateDeclaration = { + name: "workflow.with-dirty", + url: "/workflow-with-dirty", + policy: { + transition: { + dirty: { + when: (context: StateTransitionPolicyContext) => { + context.state; + context.operation; + context.transition; + + return false; + }, + prompt: "Discard changes?", + redirectTo: "workflow", + }, + }, + }, +}; + +const invalidDirtyPolicy: StateDeclaration = { + name: "invalid.dirty", + url: "/invalid-dirty", + policy: { + transition: { + dirty: { + // @ts-expect-error dirty.when must return boolean + when: (context: StateTransitionPolicyContext) => context.operation, + }, + }, + }, +}; + +const retryTransitionPolicy: StateDeclaration = { + name: "workflow.retry", + url: "/workflow-retry", + policy: { + transition: { + retry: (context: StateTransitionRetryPolicyContext) => { + context.operation; + context.from; + context.to; + context.attempt; + context.state; + + return 2; + }, + }, + }, +}; + +const retryPolicyAsBool: StateDeclaration = { + name: "workflow.retry-boolean", + url: "/workflow-retry-boolean", + policy: { + transition: { + retry: (context: StateTransitionRetryPolicyContext) => { + context.state; + + return false; + }, + }, + }, +}; + +const invalidRetryPolicy: StateDeclaration = { + name: "invalid.retry", + url: "/invalid-retry", + policy: { + transition: { + // @ts-expect-error retry expects boolean/number/injectable policy + retry: (context: StateTransitionRetryPolicyContext) => + context.attempt.toString(), + }, + }, +}; + +const transitionFallbackPolicy: StateDeclaration = { + name: "with-fallback", + url: "/with-fallback", + policy: { + transition: { + fallbackTo: "fallback", + }, + }, +}; + +const transitionFallbackPolicyWithParams: StateDeclaration = { + name: "with-fallback-params", + url: "/with-fallback-params", + policy: { + transition: { + fallbackTo: { state: "fallback", params: { reason: "retry" } }, + }, + }, +}; + +const invalidFallbackPolicy: StateDeclaration = { + name: "invalid.fallback", + url: "/invalid-fallback", + policy: { + transition: { + // @ts-expect-error fallbackTo supports only route names or state/params + // object shorthand + fallbackTo: ["fallback"], + }, + }, +}; + +const transitionErrorBoundaryPolicy: StateDeclaration = { + name: "with-error-boundary", + url: "/with-error-boundary", + policy: { + transition: { + errorBoundary: "error", + }, + }, +}; + +const transitionErrorBoundaryPolicyWithPolicy: StateDeclaration = { + name: "with-error-boundary-policy", + url: "/with-error-boundary-policy", + policy: { + transition: { + errorBoundary: (context: StateTransitionErrorPolicyContext) => { + context.operation; + context.error; + context.state; + + return { + state: "error", + params: { message: "from policy" }, + }; + }, + }, + }, +}; + +const transitionErrorAliasPolicy: StateDeclaration = { + name: "with-error-alias", + url: "/with-error-alias", + policy: { + transition: { + error: "error", + }, + }, +}; + +const transitionLoadingPolicy: StateDeclaration = { + name: "with-loading-policy", + url: "/with-loading-policy", + policy: { + transition: { + loading: (context: StateTransitionLoadingPolicyContext) => { + context.operation; + context.state; + + return true; + }, + }, + }, +}; + +const invalidErrorBoundaryPolicy: StateDeclaration = { + name: "invalid.error-boundary", + url: "/invalid-error-boundary", + policy: { + transition: { + // @ts-expect-error errorBoundary supports only route targets or boundary policy + errorBoundary: (context: StateTransitionErrorPolicyContext) => { + context.state; + + return false; + }, + }, + }, +}; + +const invalidErrorAliasPolicy: StateDeclaration = { + name: "invalid.error-alias", + url: "/invalid-error-alias", + policy: { + transition: { + // @ts-expect-error error supports only route targets or boundary policy + error: (context: StateTransitionErrorPolicyContext) => { + context.state; + + return false; + }, + }, + }, +}; + +const invalidLoadingPolicy: StateDeclaration = { + name: "invalid.loading", + url: "/invalid-loading", + policy: { + transition: { + // @ts-expect-error loading supports only boolean, string, or policy callable + loading: (context: StateTransitionLoadingPolicyContext) => { + context.from; + + return { invalid: true } as const; + }, + }, + }, +}; + +const typedRetryPolicy: StateRetryPolicy = (context) => { + context.attempt; + + return 1; +}; + +const invalidRetentionMode: StateDeclaration = { + name: "invalid.mode", + policy: { + retention: { + // @ts-expect-error retention mode must be an explicit lifecycle mode + mode: "sticky", + }, + }, +}; + +const invalidRetentionPause: StateDeclaration = { + name: "invalid.pause", + policy: { + retention: { + mode: "keep-alive", + // @ts-expect-error pause mode must be one of the supported inactive work modes + pause: "sleep", + }, + }, +}; + +const invalidRetentionEviction: StateDeclaration = { + name: "invalid.eviction", + policy: { + retention: { + mode: "keep-alive", + // @ts-expect-error eviction must be a known strategy or injectable policy + evict: "random", + }, + }, +}; + +declare const internalDeclaration: InternalStateDeclaration; +declare const builtState: BuiltStateDeclaration; +declare const registry: StateRegistryRuntime; + +internalDeclaration._state(); +builtState._state(); + +const allDeclarations: StateDeclaration[] = registry.getAll(); +const deregisteredDeclarations: StateDeclaration[] = + registry.deregister("home"); + +// @ts-expect-error public state declarations do not expose built-state internals +declaration._state; + +// @ts-expect-error registry getAll returns declarations, not built states +const builtFromGetAll: BuiltStateDeclaration[] = registry.getAll(); + +// @ts-expect-error registry deregister returns declarations, not built states +const builtFromDeregister: BuiltStateDeclaration[] = + registry.deregister("home"); + +void allDeclarations; +void deregisteredDeclarations; +void builtFromGetAll; +void builtFromDeregister; +void retainedDeclaration; +void retainedRouterTree; +void invalidRetentionMode; +void invalidRetentionPause; +void invalidRetentionEviction; +void transitionPolicyDeclaration; +void dirtyTransitionPolicyDeclaration; +void invalidTransitionPolicy; +void invalidDirtyPolicy; +void retryTransitionPolicy; +void retryPolicyAsBool; +void invalidRetryPolicy; +void transitionFallbackPolicy; +void transitionFallbackPolicyWithParams; +void invalidFallbackPolicy; +void typedRetryPolicy; diff --git a/src/router/state/state-matcher.ts b/src/router/state/state-matcher.ts index 57131ef3f..97bd96269 100644 --- a/src/router/state/state-matcher.ts +++ b/src/router/state/state-matcher.ts @@ -1,5 +1,5 @@ import { isString, keys } from "../../shared/utils.ts"; -import type { StateObject } from "./state-object.ts"; +import { getStateDeclarationSource, type StateObject } from "./state-object.ts"; import type { StateOrName, StateStore } from "./interface.ts"; export class StateMatcher { @@ -40,10 +40,27 @@ export class StateMatcher { if ( state && - (isStr || state === stateOrName || state.self === stateOrName) + (isStr || + state === stateOrName || + state.self === stateOrName || + getStateDeclarationSource(state.self) === stateOrName) ) { return state; - } else if (isStr && matchGlob) { + } else if (!isStr) { + const stateNames = keys(this._states); + + for (let index = 0; index < stateNames.length; index++) { + const candidate = this._states[stateNames[index]] as StateObject; + + if ( + candidate === stateOrName || + candidate.self === stateOrName || + getStateDeclarationSource(candidate.self) === stateOrName + ) { + return candidate; + } + } + } else if (matchGlob) { const stateNames = keys(this._states); let match: StateObject | undefined; diff --git a/src/router/state/state-object.ts b/src/router/state/state-object.ts index a92f543cf..af495ddda 100644 --- a/src/router/state/state-object.ts +++ b/src/router/state/state-object.ts @@ -11,6 +11,7 @@ import type { RawParams } from "../params/interface.ts"; import type { Resolvable } from "../resolve/resolvable.ts"; import type { BuiltStateDeclaration, + InternalStateDeclaration, RouterInjectable, StateDeclaration, ViewDeclaration, @@ -23,12 +24,33 @@ interface StateParamOptions { matchingKeys?: RawParams | null; } +const stateDeclarationSources = new WeakMap< + StateDeclaration, + StateDeclaration +>(); + +/** @internal */ +export function setStateDeclarationSource( + flattened: StateDeclaration, + source: StateDeclaration, +): void { + stateDeclarationSources.set(flattened, source); +} + +/** @internal */ +export function getStateDeclarationSource( + declaration: StateDeclaration, +): StateDeclaration | undefined { + return stateDeclarationSources.get(declaration); +} + /** * Internal representation of a ng-router state. * * Instances of this class are created when a [[StateDeclaration]] is registered with the [[StateRegistry]]. * - * A registered [[StateDeclaration]] is augmented with a getter ([[StateDeclaration._state]]) which returns the corresponding [[StateObject]] object. + * A registered declaration is internally augmented with a getter that returns + * the corresponding [[StateObject]] object. * * This class prototypally inherits from the corresponding [[StateDeclaration]]. * Each of its own properties (i.e., `hasOwnProperty`) are built using builders from the [[StateBuilder]]. @@ -83,7 +105,8 @@ export class StateObject { assign(this, config); this.self = config; this.name = config.name; - config._state = () => this as unknown as BuiltStateDeclaration; + (config as InternalStateDeclaration)._state = () => + this as unknown as BuiltStateDeclaration; const nameGlob = this.name ? Glob.fromString(this.name) : null; this._stateObjectCache = { nameGlob }; @@ -100,14 +123,19 @@ export class StateObject { * * Compares the identity of the state against the passed value, which is either an object * reference to the actual `State` instance, the original definition object passed to - * `$stateProvider.state()`, or the fully-qualified name. + * `app.router()`, or the fully-qualified name. * * @param ref Can be one of (a) a `State` instance, (b) an object that was passed - * into `$stateProvider.state()`, (c) the fully-qualified name of a state as a string. + * into `app.router()`, (c) the fully-qualified name of a state as a string. * @returns Returns `true` if `ref` matches the current `State` instance. */ is(ref: StateObject | StateDeclaration | string): boolean { - return this === ref || this.self === ref || this._pathName() === ref; + return ( + this === ref || + this.self === ref || + getStateDeclarationSource(this.self) === ref || + this._pathName() === ref + ); } /** diff --git a/src/router/state/state-registry.ts b/src/router/state/state-registry.ts index 74c476c04..7dd3fb74d 100644 --- a/src/router/state/state-registry.ts +++ b/src/router/state/state-registry.ts @@ -1,49 +1,56 @@ -import { - _compileProvider, - _injector, - _routerProvider, -} from "../../injection-tokens.ts"; import { StateMatcher } from "./state-matcher.ts"; import { StateBuilder } from "./state-builder.ts"; import { StateObject } from "./state-object.ts"; import { annotate } from "../../core/di/di.ts"; -import type { CompileProvider } from "../../core/compile/compile.ts"; -import type { ResolveContext } from "../resolve/resolve-context.ts"; -import { - assertDefined, - deleteProperty, - hasOwn, - isString, - keys, -} from "../../shared/utils.ts"; -import type { InjectorService } from "../../core/di/internal-injector.ts"; +import type { CompileRegistry } from "../../core/compile/compile.ts"; +import { deleteProperty, hasOwn, isString, keys } from "../../shared/utils.ts"; import type { BuiltStateDeclaration, + InternalStateDeclaration, StateDeclarationInput, StateDeclaration, StateOrName, StateRegistryListener, StateStore, } from "./interface.ts"; -import type { RouterProvider } from "../router.ts"; +import type { RouterRuntimeState } from "../router.ts"; function stateOrNameToString(stateOrName: StateOrName): string { return isString(stateOrName) ? stateOrName : stateOrName.name; } +/** + * Public `$stateRegistry` contract for dynamic route registration. + * + * Module-owned static routes should normally use [[NgModule.router]]. Use this + * service when routes must be added or removed at runtime. + */ +export interface StateRegistryService { + onStatesChanged( + listener: ( + event: "registered" | "deregistered", + states: StateDeclaration[], + ) => void, + ): () => void; + root(): StateDeclaration; + register(stateDefinition: StateDeclaration): StateDeclaration; + deregister(stateOrName: StateOrName): StateDeclaration[]; + getAll(): StateDeclaration[]; + get(): StateDeclaration[]; + get(stateOrName: StateOrName, base?: StateOrName): StateDeclaration | null; +} + /** * A registry for all of the application's [[StateDeclaration]]s * * This API is found at `$stateRegistry`. * */ -export class StateRegistryProvider { - /* @ignore */ static $inject = [_routerProvider, _compileProvider]; - +export class StateRegistryRuntime implements StateRegistryService { /** @internal */ _states: StateStore; /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; /** @internal */ _$injector: ng.InjectorService | undefined; /** @internal */ @@ -57,7 +64,10 @@ export class StateRegistryProvider { /** @internal */ _root!: StateObject; - constructor(routerState: RouterProvider, compileProvider: CompileProvider) { + constructor( + routerState: RouterRuntimeState, + compileRegistry: CompileRegistry, + ) { this._states = {}; this._routerState = routerState; @@ -71,38 +81,32 @@ export class StateRegistryProvider { this._builder = new StateBuilder( this._matcher, routerState, - compileProvider, + compileRegistry, ); this._queue = []; this.registerRoot(); - routerState._currentState = this.root(); + routerState._currentState = this._root; routerState._current = routerState._currentState.self; } - $get = [ - _injector, - /** - * @param {InjectorService} $injector - * @returns {StateRegistryProvider} - */ - ($injector: InjectorService) => { - this._$injector = $injector; - this._builder._$injector = $injector; - this._annotateDeferredResolvables($injector.strictDi); - - return this; - }, - ]; + /** @internal */ + _initRuntime($injector: ng.InjectorService): this { + this._$injector = $injector; + this._builder._$injector = $injector; + this._annotateDeferredResolvables($injector.strictDi); + + return this; + } /** @internal */ _annotateDeferredResolvables(strictDi: boolean | undefined): void { - const states = this.getAll(); + const states = this._getAllBuilt(); states.forEach((state) => { - const { resolvables } = state._state(); + const { resolvables } = state; resolvables.forEach((resolvable) => { if (resolvable.deps === "deferred") { @@ -176,12 +180,10 @@ export class StateRegistryProvider { * * Gets the root of the state tree. * The root state is implicitly created by ng-router. - * Note: this returns the internal [[StateObject]] representation, not a [[StateDeclaration]] - * - * @return the root [[StateObject]] + * @return the public root state declaration */ - root(): StateObject { - return this._root; + root(): StateDeclaration { + return this._root.self; } /** @@ -192,12 +194,10 @@ export class StateRegistryProvider { * Note: a state will be queued if the state's parent isn't yet registered. * * @param {StateDeclarationInput} stateDefinition the definition of the state to register. - * @returns the internal [[StateObject]] object. - * If the state was successfully registered, then the object is fully built (See: [[StateBuilder]]). - * If the state was only queued, then the object is not fully built. + * @returns the registered public state declaration. */ - register(stateDefinition: StateDeclarationInput): StateObject { - return this._register(stateDefinition); + register(stateDefinition: StateDeclarationInput): StateDeclaration { + return this._register(stateDefinition).self; } /** @internal */ @@ -325,13 +325,7 @@ export class StateRegistryProvider { */ /** @internal */ _deregisterTree(state: BuiltStateDeclaration): BuiltStateDeclaration[] { - const allDeclarations = this.getAll(); - - const all: BuiltStateDeclaration[] = []; - - allDeclarations.forEach((declaration) => { - all.push(declaration._state()); - }); + const all = this._getAllBuilt(); const children: BuiltStateDeclaration[] = []; @@ -370,17 +364,19 @@ export class StateRegistryProvider { * If the state has children, they are are also removed from the registry. * * @param {StateOrName} stateOrName the state's name or object representation - * @returns {BuiltStateDeclaration[]} a list of removed states + * @returns {StateDeclaration[]} a list of removed state declarations */ - deregister(stateOrName: StateOrName): BuiltStateDeclaration[] { - const state = this.get(stateOrName) as BuiltStateDeclaration | null; + deregister(stateOrName: StateOrName): StateDeclaration[] { + const stateDeclaration = this.get( + stateOrName, + ) as InternalStateDeclaration | null; - if (!state) { + if (!stateDeclaration) { throw new Error( `Can't deregister state; not found: ${stateOrNameToString(stateOrName)}`, ); } - const deregisteredStates = this._deregisterTree(state._state()); + const deregisteredStates = this._deregisterTree(stateDeclaration._state()); const deregisteredDeclarations: StateDeclaration[] = []; @@ -390,30 +386,37 @@ export class StateRegistryProvider { this._notifyListeners("deregistered", deregisteredDeclarations); - return deregisteredStates; + return deregisteredDeclarations; } - /** - * @return {ng.BuiltStateDeclaration[]} - */ - getAll(): BuiltStateDeclaration[] { + /** @internal */ + _getAllBuilt(): BuiltStateDeclaration[] { const stateNames = keys(this._states); const states: BuiltStateDeclaration[] = []; stateNames.forEach((name) => { - states.push(this._states[name].self as BuiltStateDeclaration); + states.push(this._states[name] as BuiltStateDeclaration); }); return states; } + /** + * @return {StateDeclaration[]} + */ + getAll(): StateDeclaration[] { + return this._getAllBuilt().map((state) => state.self); + } + /** * * @param {StateOrName} [stateOrName] * @param {StateOrName} [base] * @returns {StateDeclaration | StateDeclaration[] | null} */ + get(): StateDeclaration[]; + get(stateOrName: StateOrName, base?: StateOrName): StateDeclaration | null; get( stateOrName?: StateOrName, base?: StateOrName, @@ -438,17 +441,3 @@ export class StateRegistryProvider { return found?.self ?? null; } } - -export function getLocals(ctx: ResolveContext): Record { - const tokens = ctx.getTokens(); - - const locals: Record = {}; - - tokens.forEach((key) => { - if (isString(key)) { - locals[key] = assertDefined(ctx.getResolvable(key)).data; - } - }); - - return locals; -} diff --git a/src/router/state/state-service.ts b/src/router/state/state-service.ts index 3130af5ba..b1aa9b2fa 100644 --- a/src/router/state/state-service.ts +++ b/src/router/state/state-service.ts @@ -1,20 +1,11 @@ -import { - _exceptionHandlerProvider, - _injector, - _router, - _routerProvider, - _rootScope, - _stateRegistry, - _stateRegistryProvider, - _transitionsProvider, - _view, -} from "../../injection-tokens.ts"; import { defaults } from "../../shared/common.ts"; import { isDefined, isArray, isNullOrUndefined, isObject, + isFunction, + isInstanceOf, createErrorFactory, isString, assertDefined, @@ -22,30 +13,46 @@ import { import { PathNode } from "../path/path-node.ts"; import { defaultTransOpts, - type TransitionProvider, + type TransitionRuntime, } from "../transition/transition-service.ts"; import { Rejection } from "../transition/reject-factory.ts"; import { TargetState } from "./target-state.ts"; +import { + createTransitionErrorPolicyInvocationLocals, + createTransitionPolicyInvocationLocals, +} from "../invocation-context.ts"; import { Param } from "../params/param.ts"; import { Glob } from "../glob/glob.ts"; +import { + getTransitionRetryPolicyFromStateName, + transitionToState, +} from "./state-transition.ts"; import type { RawParams } from "../params/interface.ts"; import type { ViewService } from "../view/view.ts"; -import type { TransitionOptions } from "../transition/interface.ts"; +import type { + InternalTransitionOptions, + TransitionOptions, +} from "../transition/interface.ts"; import type { HrefOptions, LazyStateLoader, LazyStateLoadResult, + RouteMap, + ParamsOf, + StateRetryPolicy, StateDeclaration, StateOrName, + StateTransitionFallbackPolicy, StateTransitionResult, TransitionPromise, + StateErrorBoundaryPolicy, + StateTransitionErrorPolicyContext, } from "./interface.ts"; import type { StateObject } from "./state-object.ts"; -import type { StateRegistryProvider } from "./state-registry.ts"; -import type { RouterProvider } from "../router.ts"; -import { transitionToState } from "./state-transition.ts"; +import type { StateRegistryRuntime } from "./state-registry.ts"; +import type { RouterRuntimeState } from "../router.ts"; -const stateProviderError = createErrorFactory("$stateProvider"); +const stateRuntimeError = createErrorFactory("$state"); export interface LazyStateRegistration { prefix: string; @@ -54,27 +61,53 @@ export interface LazyStateRegistration { loaded: boolean; } -/** - * Provides services related to ng-router states. - * - * This API is located at `$state`. - */ -export class StateProvider { +function diagnosticStateName(stateOrName: StateOrName): string { + return isString(stateOrName) ? stateOrName : stateOrName.name; +} + +/** @internal */ +export type StateTransitionPolicyDiagnosticKind = "retry" | "fallback"; + +/** @internal */ +export type StateTransitionPolicyDiagnosticDecision = + | "retry" + | "blocked" + | "redirected" + | "skipped"; + +/** @internal */ +export interface StateTransitionPolicyDiagnostic { + _kind: StateTransitionPolicyDiagnosticKind; + _decision: StateTransitionPolicyDiagnosticDecision; + _from: string | undefined; + _to: string | undefined; + _policyState: string | undefined; + _attempt?: number; + _target?: string; + _reason?: string; +} + +/** @internal */ +export class StateRuntime { /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; /** @internal */ - _transitionService: TransitionProvider; + _transitionService: TransitionRuntime; /** @internal */ - _stateRegistry: StateRegistryProvider; + _stateRegistry: StateRegistryRuntime; /** @internal */ _$injector: ng.InjectorService | undefined; /** @internal */ _lazyStates: LazyStateRegistration[]; /** @internal */ _defaultErrorHandler: ng.ExceptionHandlerService; + /** @internal */ + _policyDiagnostics: StateTransitionPolicyDiagnostic[]; + /** @internal */ + _viewService!: ViewService; /** @internal */ - _getRegistry(): StateRegistryProvider { + _getRegistry(): StateRegistryRuntime { return this._stateRegistry; } @@ -101,63 +134,48 @@ export class StateProvider { return this._routerState._currentState; } - /* @ignore */ - static $inject = [ - _stateRegistryProvider, - _routerProvider, - _transitionsProvider, - _exceptionHandlerProvider, - ]; - constructor( - stateRegistry: StateRegistryProvider, - routerState: RouterProvider, - transitionService: TransitionProvider, - exceptionHandlerProvider: ng.ExceptionHandlerProvider, + stateRegistry: StateRegistryRuntime, + routerState: RouterRuntimeState, + transitionService: TransitionRuntime, + exceptionHandler: ng.ExceptionHandlerService, ) { this._routerState = routerState; this._transitionService = transitionService; this._stateRegistry = stateRegistry; this._$injector = undefined; this._lazyStates = []; + this._policyDiagnostics = []; - this._defaultErrorHandler = exceptionHandlerProvider.handler; + this._defaultErrorHandler = exceptionHandler; } - $get = [ - _injector, - _stateRegistry, - _router, - _rootScope, - _view, - /** - * @param {ng.InjectorService} $injector - * @param {StateRegistryProvider} $stateRegistry - * @param {RouterProvider} routerState - * @param {ng.RootScopeService} $rootScope - * @param viewService - * @returns {StateProvider} - */ - ( - $injector: ng.InjectorService, - $stateRegistry: StateRegistryProvider, - routerState: RouterProvider, - $rootScope: ng.RootScopeService, - viewService: ViewService, - ) => { - this._stateRegistry = $stateRegistry; - this._routerState = routerState; - routerState._stateService = this; - $rootScope.$on("$locationChangeSuccess", (evt: ng.ScopeEvent) => { - routerState._sync(evt); - }); - this._transitionService._initRuntimeHooks(this, viewService); - this._$injector = $injector; - this._routerState._injector = $injector; + /** @internal */ + _recordPolicyDiagnostic(diagnostic: StateTransitionPolicyDiagnostic): void { + this._policyDiagnostics.push(diagnostic); + } - return this; - }, - ]; + /** @internal */ + _initRuntime( + $injector: ng.InjectorService, + $location: ng.LocationService, + $stateRegistry: StateRegistryRuntime, + $rootScope: ng.Scope, + viewService: ViewService, + ): this { + this._routerState._initRuntime($location, $injector); + this._stateRegistry = $stateRegistry; + this._viewService = viewService; + this._routerState._stateService = this; + $rootScope.$on("$locationChangeSuccess", (evt: ng.ScopeEvent) => { + this._routerState._sync(evt); + }); + this._transitionService._initRuntimeHooks(this, viewService); + this._$injector = $injector; + this._routerState._injector = $injector; + + return this; + } /** * Register a router state. @@ -184,13 +202,13 @@ export class StateProvider { ); if (!stateDefinition.name) { - throw stateProviderError("stateinvalid", `'name' required`); + throw stateRuntimeError("stateinvalid", `'name' required`); } try { this._getRegistry().register(stateDefinition); } catch (err) { - throw stateProviderError("stateinvalid", (err as Error).message); + throw stateRuntimeError("stateinvalid", (err as Error).message); } return this; @@ -231,6 +249,9 @@ export class StateProvider { const routerState = this._routerState; const latest = routerState._lastStartedTransition; + const retryPolicy = this._getTransitionRetryPolicyFromStateName( + toState.name(), + ); const lazy = this._findLazyState(toState); @@ -238,9 +259,37 @@ export class StateProvider { return Rejection.invalid(toState.error())._toPromise(); } - lazy.promise ??= this._loadLazyRegistration(lazy, toState); + let attempts = 1; + + for (;;) { + lazy.promise ??= this._loadLazyRegistration(lazy, toState); - await lazy.promise; + try { + await lazy.promise; + break; + } catch (error) { + const shouldRetry = await this._shouldRetryLazyLoad( + retryPolicy, + attempts, + error, + toState.name(), + ); + + lazy.promise = undefined; + + if (!shouldRetry) { + const recovered = await this._recoverLazyLoadFailure(toState, error); + + if (recovered) { + return recovered; + } + + throw error; + } + + attempts += 1; + } + } if (routerState._lastStartedTransition !== latest) { return Rejection.superseded()._toPromise(); @@ -263,6 +312,364 @@ export class StateProvider { ); } + _getStatePolicyFromStateName( + stateName: StateOrName, + readPolicy: (state: StateDeclaration) => TPolicy | undefined, + ): + | { + state: StateDeclaration; + policy: TPolicy; + } + | undefined { + const stateNameString = diagnosticStateName(stateName); + + if (stateNameString) { + const tokens = stateNameString.split("."); + + for (let i = tokens.length; i > 0; i--) { + const candidateName = tokens.slice(0, i).join("."); + const state = this._stateRegistry.get(candidateName); + + if (!state) continue; + + const policy = readPolicy(state); + + if (policy !== undefined) { + return { + state, + policy, + }; + } + } + } + + return undefined; + } + + _getTransitionFallbackPolicyFromStateName(stateName: StateOrName): + | { + state: StateDeclaration; + policy: StateTransitionFallbackPolicy; + } + | undefined { + return ( + this._getStatePolicyFromStateName( + stateName, + (state) => state.policy?.transition?.fallbackTo, + ) ?? + (this._routerState._fallbackTo !== undefined + ? { + state: this._stateRegistry._root.self, + policy: this._routerState._fallbackTo, + } + : undefined) + ); + } + + _getTransitionErrorBoundaryPolicyFromStateName(stateName: StateOrName): + | { + state: StateDeclaration; + policy: + | string + | { state?: string; params?: RawParams } + | TargetState + | StateErrorBoundaryPolicy + | undefined; + } + | undefined { + const routePolicy = this._getStatePolicyFromStateName( + stateName, + (state) => { + return ( + state.policy?.transition?.error ?? + state.policy?.transition?.errorBoundary + ); + }, + ); + + if (routePolicy) return routePolicy; + + const routerPolicy = + this._routerState._error ?? this._routerState._errorBoundary; + + return routerPolicy !== undefined + ? { + state: this._stateRegistry._root.self, + policy: routerPolicy, + } + : undefined; + } + + _buildLazyFallbackTarget( + toState: TargetState, + policy: StateTransitionFallbackPolicy, + ): TargetState | undefined { + if (isString(policy)) { + return this.target(policy, toState.params(), toState.options()); + } + + if ( + isObject(policy) && + (Object.hasOwn(policy, "state") || Object.hasOwn(policy, "params")) + ) { + const redirect = policy as { state?: string; params?: RawParams }; + + return this.target( + redirect.state ?? toState.name(), + redirect.params ?? toState.params(), + toState.options(), + ); + } + + return undefined; + } + + async _buildLazyErrorBoundaryTarget( + toState: TargetState, + policyState: StateDeclaration, + policy: + | string + | { state?: string; params?: RawParams } + | TargetState + | StateErrorBoundaryPolicy + | undefined, + error: unknown, + ): Promise { + if (isString(policy)) { + return this.target(policy, toState.params(), toState.options()); + } + + if (isInstanceOf(policy, TargetState)) { + return policy; + } + + if ( + isObject(policy) && + (Object.hasOwn(policy, "state") || Object.hasOwn(policy, "params")) + ) { + const redirect = policy as { state?: string; params?: RawParams }; + + return this.target( + redirect.state ?? toState.name(), + redirect.params ?? toState.params(), + toState.options(), + ); + } + + if (!isFunction(policy)) { + return undefined; + } + + const from = this._routerState._current ?? this._stateRegistry._root.self; + const to = + this._routerState._currentState?.self ?? this._stateRegistry._root.self; + const context: StateTransitionErrorPolicyContext = { + operation: "error", + transition: undefined, + from, + to, + state: policyState, + error, + }; + + const result = await Promise.resolve( + this._routerState._injector?.invoke( + policy, + undefined, + createTransitionErrorPolicyInvocationLocals(context), + "route error boundary policy", + ), + ); + + if (isInstanceOf(result, TargetState)) { + return result; + } + + if (isString(result)) { + return this.target(result, toState.params(), toState.options()); + } + + if ( + isObject(result) && + (Object.hasOwn(result, "state") || Object.hasOwn(result, "params")) + ) { + const redirect = result as { state?: string; params?: RawParams }; + + return this.target( + redirect.state ?? toState.name(), + redirect.params ?? toState.params(), + toState.options(), + ); + } + + if (result === undefined) { + return undefined; + } + + throw new Error( + "Route error boundary policy must return TargetState, redirect target, or undefined.", + ); + } + + async _recoverLazyLoadFailure( + toState: TargetState, + error: unknown, + ): Promise { + const errorPolicy = this._getTransitionErrorBoundaryPolicyFromStateName( + toState.name(), + ); + + if (errorPolicy) { + const errorTarget = await this._buildLazyErrorBoundaryTarget( + toState, + errorPolicy.state, + errorPolicy.policy, + error, + ); + + if (errorTarget?.valid() && errorTarget.name() !== toState.name()) { + return this.transitionTo( + errorTarget.identifier(), + errorTarget.params(), + errorTarget.options(), + ); + } + } + + const fallbackPolicy = this._getTransitionFallbackPolicyFromStateName( + toState.name(), + ); + + if (!fallbackPolicy) return undefined; + + const fallbackTarget = this._buildLazyFallbackTarget( + toState, + fallbackPolicy.policy, + ); + + if (!fallbackTarget?.valid()) { + this._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "skipped", + _from: this._routerState._current?.name, + _to: toState.name() as string | undefined, + _policyState: fallbackPolicy.state.name, + _reason: "invalid-target", + }); + + return undefined; + } + + if (fallbackTarget.name() === toState.name()) { + this._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "skipped", + _from: this._routerState._current?.name, + _to: toState.name() as string | undefined, + _policyState: fallbackPolicy.state.name, + _target: fallbackTarget.name() as string, + _reason: "same-target", + }); + + return undefined; + } + + this._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "redirected", + _from: this._routerState._current?.name, + _to: toState.name() as string | undefined, + _policyState: fallbackPolicy.state.name, + _target: fallbackTarget.name() as string, + }); + + return this.transitionTo( + fallbackTarget.identifier(), + fallbackTarget.params(), + fallbackTarget.options(), + ); + } + + _getTransitionRetryPolicyFromStateName(stateName: StateOrName): + | { + state: StateDeclaration; + policy: boolean | number | StateRetryPolicy; + } + | undefined { + return getTransitionRetryPolicyFromStateName(this, stateName); + } + + async _shouldRetryLazyLoad( + retryPolicy: + | { + state: StateDeclaration; + policy: boolean | number | StateRetryPolicy; + } + | undefined, + attempt: number, + error: unknown, + targetName: StateOrName, + ): Promise { + if (!retryPolicy) return false; + + let decision: unknown = retryPolicy.policy; + + if (typeof decision !== "boolean" && typeof decision !== "number") { + if (isFunction(retryPolicy.policy)) { + const from = + this._routerState._current ?? this._stateRegistry._root.self; + const to = + this._routerState._currentState?.self ?? + this._stateRegistry._root.self; + + const context = { + operation: "retry", + attempt, + from, + to, + state: retryPolicy.state, + transition: undefined, + error, + }; + + const resolved = this._routerState._injector?.invoke( + retryPolicy.policy, + undefined, + createTransitionPolicyInvocationLocals(context), + "route retry policy", + ); + decision = await Promise.resolve(resolved); + } + } + + if (typeof decision !== "boolean" && typeof decision !== "number") { + throw new Error("Route retry policy must return boolean or number."); + } + + const maxAttempts = this._normalizeRetryPolicy(decision); + const allowed = !!maxAttempts && attempt < maxAttempts; + + this._recordPolicyDiagnostic({ + _kind: "retry", + _decision: allowed ? "retry" : "blocked", + _from: this._routerState._current?.name, + _to: diagnosticStateName(targetName), + _policyState: retryPolicy.state.name, + _attempt: attempt, + }); + + return allowed; + } + + _normalizeRetryPolicy(value: boolean | number): number | undefined { + if (value === true) return 2; + if (value === false) return 0; + + if (!Number.isFinite(value) || value < 1) return 0; + + return Math.trunc(value); + } + /** @internal */ async _loadLazyRegistration( lazy: LazyStateRegistration, @@ -291,62 +698,6 @@ export class StateProvider { return this; } - /** - * Reloads the current state - * - * A method that force reloads the current state, or a partial state hierarchy. - * All resolves are re-resolved, and components reinstantiated. - * - * #### Example: - * ```js - * let app = angular.module('app', []); - * - * app.controller('ctrl', function ($scope, $state) { - * $scope.reload = function(){ - * $state.reload(); - * } - * }); - * ``` - * - * Note: `reload()` is just an alias for: - * - * ```js - * $state.transitionTo($state.current, $state.params, { - * reload: true, inherit: false - * }); - * ``` - * - * @param {string | StateDeclaration | StateObject} [reloadState] A state name or a state object. - * If present, this state and all its children will be reloaded, but ancestors will not reload. - * - * #### Example: - * ```js - * //assuming app application consists of 3 states: 'contacts', 'contacts.detail', 'contacts.detail.item' - * //and current state is 'contacts.detail.item' - * let app = angular.module('app', []); - * - * app.controller('ctrl', function ($scope, $state) { - * $scope.reload = function(){ - * //will reload 'contact.detail' and nested 'contact.detail.item' states - * $state.reload('contact.detail'); - * } - * }); - * ``` - * - * @returns A promise representing the state of the new transition. See [[StateService.go]] - */ - - reload(reloadState?: string | StateDeclaration | StateObject) { - const current = this._routerState._current; - - if (!current) throw new Error("No current state"); - - return this.transitionTo(current, this._routerState._params, { - reload: isDefined(reloadState) ? reloadState : true, - inherit: false, - }); - } - /** * Transition to a different state and/or parameters * @@ -378,7 +729,7 @@ export class StateProvider { * - `$state.go('^.sibling')` - if current state is `home.child`, will go to the `home.sibling` state * - `$state.go('.child.grandchild')` - if current state is home, will go to the `home.child.grandchild` state * - * @param {*} [params] A map of the parameters that will be sent to the state, will populate $stateParams. + * @param {*} [params] A map of the parameters that will be sent to the state and exposed on `$state.params`. * * Any parameters that are not specified will be inherited from current parameter values (because of `inherit: true`). * This allows, for example, going to a sibling state that shares parameters defined by a parent state. @@ -392,14 +743,14 @@ export class StateProvider { to: StateOrName, params?: RawParams, options?: TransitionOptions, - ): TransitionPromise | Promise { + ): TransitionPromise { const defautGoOpts = { relative: this.$current, inherit: true }; const transOpts = defaults( options, defautGoOpts, defaultTransOpts, - ) as TransitionOptions; + ) as InternalTransitionOptions; return this.transitionTo(to, params, transOpts); } @@ -419,30 +770,38 @@ export class StateProvider { params: RawParams = {}, options: TransitionOptions = {}, ): TargetState { + const internalOptions: InternalTransitionOptions = { ...options }; + // If we're reloading, find the state object to reload from - if (isObject(options.reload) && !options.reload.name) + if (isObject(internalOptions.reload) && !internalOptions.reload.name) throw new Error("Invalid reload state object"); const reg = this._getRegistry(); - options.reloadState = - options.reload === true - ? reg.root() - : options.reload - ? reg._matcher.find(options.reload, options.relative) + internalOptions.reloadState = + internalOptions.reload === true + ? reg._root + : internalOptions.reload + ? reg._matcher.find(internalOptions.reload, internalOptions.relative) : undefined; - if (options.reload && !options.reloadState) + if (internalOptions.reload && !internalOptions.reloadState) throw new Error( `No such reload state '${ - isString(options.reload) - ? options.reload - : isObject(options.reload) && "name" in options.reload - ? options.reload.name - : String(options.reload) + isString(internalOptions.reload) + ? internalOptions.reload + : isObject(internalOptions.reload) && + "name" in internalOptions.reload + ? internalOptions.reload.name + : String(internalOptions.reload) }'`, ); - return new TargetState(this._getRegistry(), identifier, params, options); + return new TargetState( + this._getRegistry(), + identifier, + params, + internalOptions, + ); } getCurrentPath(): PathNode[] { @@ -452,137 +811,33 @@ export class StateProvider { return latestSuccess ? latestSuccess._treeChanges.to - : [new PathNode(this._getRegistry().root())]; + : [new PathNode(this._getRegistry()._root)]; } - /** - * Low-level method for transitioning to a new state. - * - * The [[go]] method (which uses `transitionTo` internally) is recommended in most situations. - * - * #### Example: - * ```js - * let app = angular.module('app', []); - * - * app.controller('ctrl', function ($scope, $state) { - * $scope.changeState = function () { - * $state.transitionTo('contact.detail'); - * }; - * }); - * ``` - * - * @param {StateOrName} to State name or state object. - * @param {RawParams} toParams A map of the parameters that will be sent to the state, - * will populate $stateParams. - * @param {TransitionOptions} options Transition options - * - * @returns A promise representing the state of the new transition. See [[go]] - */ - + /** @internal */ transitionTo( to: StateOrName, toParams: RawParams = {}, options: TransitionOptions = {}, - ): TransitionPromise | Promise { + ): TransitionPromise { return transitionToState(this, to, toParams, options); } /** - * Checks if the current state *is* the provided state - * - * Similar to [[includes]] but only checks for the full state name. - * If params is supplied then it will be tested for strict equality against the current - * active params object, so all params must match with none missing and no extras. - * - * #### Example: - * ```js - * $state.$current.name = 'contacts.details.item'; - * - * // absolute name - * $state.is('contact.details.item'); // returns true - * $state.is(contactDetailItemStateObject); // returns true - * ``` - * - * // relative name (. and ^), typically from a template - * // E.g. from the 'contacts.details' template - * ```html - *
Item
- * ``` - * @param {StateOrName} stateOrName The state name (absolute or relative) or state object you'd like to check. - * @param {RawParams} [params] A param object, e.g. `{sectionId: section.id}`, that you'd like - to test against the current active state. - * @param {{ relative: StateOrName | undefined; } | undefined} [options] An options object. The options are: - - `relative`: If `stateOrName` is a relative state name and `options.relative` is set, .is will - test relative to `options.relative` state (or name). - * @returns {boolean | undefined} Returns true if it is the state. - */ - is( - stateOrName: StateOrName, - params?: RawParams, - options?: { relative: StateOrName | undefined }, - ): boolean | undefined { - const relative = options?.relative ?? this.$current; - - const state = this._stateRegistry._matcher.find(stateOrName, relative); - - if (!isDefined(state)) return undefined; - - if (this.$current !== state) return false; - - if (!params) return true; - const schema = state.parameters({ inherit: true, matchingKeys: params }); - - return Param.equals( - schema, - Param.values(schema, params), - this._routerState._params, - ); - } - - /** - * Checks if the current state *includes* the provided state - * - * A method to determine if the current active state is equal to or is the child of the - * state stateName. If any params are passed then they will be tested for a match as well. - * Not all the parameters need to be passed, just the ones you'd like to test for equality. - * - * #### Example when `$state.$current.name === 'contacts.details.item'` - * ```js - * // Using partial names - * $state.includes("contacts"); // returns true - * $state.includes("contacts.details"); // returns true - * $state.includes("contacts.details.item"); // returns true - * $state.includes("contacts.list"); // returns false - * $state.includes("about"); // returns false - * ``` - * - * #### Glob Examples when `* $state.$current.name === 'contacts.details.item.url'`: - * ```js - * $state.includes("*.details.*.*"); // returns true - * $state.includes("*.details.**"); // returns true - * $state.includes("**.item.**"); // returns true - * $state.includes("*.details.item.url"); // returns true - * $state.includes("*.details.*.url"); // returns true - * $state.includes("*.details.*"); // returns false - * $state.includes("item.**"); // returns false - * ``` - * @param {StateOrName} stateOrName A partial name, relative name, glob pattern, - or state object to be searched for within the current state name. - * @param {RawParams} [params] A param object, e.g. `{sectionId: section.id}`, - that you'd like to test against the current active state. - * @param {TransitionOptions} [options] An options object. The options are: - - `relative`: If `stateOrName` is a relative state name and `options.relative` is set, .is will - test relative to `options.relative` state (or name). - * @returns {boolean | undefined} Returns true if it does include the state - */ - includes( + * Checks whether the current state matches a state, ancestor, or glob. + * Set `exact` to require the current state itself instead of an ancestor. + */ + matches( stateOrName: StateOrName, params?: RawParams, - options?: TransitionOptions, - ): boolean | undefined { + options?: { exact?: boolean; relative?: StateOrName }, + ): boolean { const relative = options?.relative ?? this.$current; - const glob = isString(stateOrName) && Glob.fromString(stateOrName); + const glob = + !options?.exact && isString(stateOrName) + ? Glob.fromString(stateOrName) + : undefined; if (glob) { const currentName = this.$current?.name; @@ -592,11 +847,15 @@ export class StateProvider { } const state = this._stateRegistry._matcher.find(stateOrName, relative); - const include = this.$current?.includes; + if (!isDefined(state)) return false; - if (!isDefined(state) || !include) return undefined; + if (options?.exact) { + if (this.$current !== state) return false; + } else { + const include = this.$current?.includes; - if (!isDefined(include[state.name])) return false; + if (!include || !isDefined(include[state.name])) return false; + } if (!params) return true; const schema = state.parameters({ @@ -654,53 +913,117 @@ export class StateProvider { }); } - /** - * Sets or gets the default [[transitionTo]] error handler. - * - * The error handler is called when a [[Transition]] is rejected or when any error occurred during the Transition. - * This includes errors caused by resolves and transition hooks. - * - * Note: - * This handler does not receive certain Transition rejections. - * Redirected and Ignored Transitions are not considered to be errors by [[StateService.transitionTo]]. - * - * The built-in default error handler logs the error to the console. - * - * You can provide your own custom handler. - * - * #### Example: - * ```js - * stateService.defaultErrorHandler(function() { - * // Do not log transitionTo errors - * }); - * ``` - * @param {ng.ExceptionHandlerService | undefined} [handler] a global error handler function - * @returns the current global error handler - */ - defaultErrorHandler(handler?: ng.ExceptionHandlerService) { - return (this._defaultErrorHandler = handler ?? this._defaultErrorHandler); - } - /** * @param {StateOrName} stateOrName * @param {undefined} [base] */ + get(): StateDeclaration[]; + get( + stateOrName: StateOrName, + base?: StateOrName, + ): StateDeclaration | undefined; get(stateOrName?: StateOrName, base?: StateOrName) { const reg = this._stateRegistry; if (arguments.length === 0) return reg.get(); + if (stateOrName === undefined) return undefined; return reg.get(stateOrName, base ?? this.$current); } } +/** + * `$state` facade with an optional public route map. Supplying the route map + * narrows route names and params without exposing internal router state + * records. + */ +type RequiredRouteParamKeys> = + string extends keyof TParams + ? never + : { + [TKey in keyof TParams]-?: Record extends Pick< + TParams, + TKey + > + ? never + : TKey; + }[keyof TParams]; + +type RouteParamArgs< + TRouteMap extends RouteMap, + TRouteName extends Extract, + TOptions, +> = + RequiredRouteParamKeys> extends never + ? [params?: ParamsOf, options?: TOptions] + : [params: ParamsOf, options?: TOptions]; + +export type StateService> = { + /** The latest successful state parameters. */ + readonly params: RawParams; + + /** The current state declaration, when navigation has selected one. */ + readonly current: StateDeclaration | undefined; + + /** Overload for typed route names and params. */ + go>( + to: TRouteName, + ...args: RouteParamArgs + ): TransitionPromise; + + /** Untyped overload used when no route map is supplied. */ + go( + to: TRouteMap extends Record + ? StateOrName + : Exclude, + params?: RawParams, + options?: TransitionOptions, + ): TransitionPromise; + + /** Overload for typed route names and params. */ + href>( + stateOrName: TRouteName, + ...args: RouteParamArgs + ): string | null; + + /** Untyped overload used when no route map is supplied. */ + href( + stateOrName: TRouteMap extends Record + ? StateOrName + : Exclude, + params?: RawParams, + options?: HrefOptions, + ): string | null; + + /** Build a target that can be returned from a transition hook. */ + target( + identifier: StateOrName, + params?: RawParams, + options?: TransitionOptions, + ): TargetState; + + /** Get all states or a matching public state declaration. */ + get(): StateDeclaration[]; + get( + stateOrName?: StateOrName, + base?: StateOrName, + ): StateDeclaration | undefined; + + /** Check whether the current state matches a state, ancestor, or glob. */ + matches( + stateOrName: StateOrName, + params?: RawParams, + options?: { exact?: boolean; relative?: StateOrName }, + ): boolean; +}; + function normalizeStateDeclaration( nameOrDefinition: string | StateDeclaration, definition?: Omit, ): StateDeclaration { if (isString(nameOrDefinition)) { if (!isObject(definition)) { - throw stateProviderError("stateinvalid", `'definition' required`); + throw stateRuntimeError("stateinvalid", `'definition' required`); } const namedDefinition = definition as StateDeclaration; @@ -709,7 +1032,7 @@ function normalizeStateDeclaration( isDefined(namedDefinition.name) && namedDefinition.name !== nameOrDefinition ) { - throw stateProviderError( + throw stateRuntimeError( "stateinvalid", `State name '${namedDefinition.name}' does not match '${nameOrDefinition}'`, ); diff --git a/src/router/state/state-transition.ts b/src/router/state/state-transition.ts index fdfc8e1fd..c68278085 100644 --- a/src/router/state/state-transition.ts +++ b/src/router/state/state-transition.ts @@ -1,17 +1,495 @@ import { defaults } from "../../shared/common.ts"; -import { assign, isInstanceOf } from "../../shared/utils.ts"; +import { + assign, + isInstanceOf, + isObject, + isFunction, + isString, +} from "../../shared/utils.ts"; import { defaultTransOpts } from "../transition/transition-service.ts"; +import { + createTransitionErrorPolicyInvocationLocals, + createTransitionPolicyInvocationLocals, +} from "../invocation-context.ts"; import { RejectType, Rejection } from "../transition/reject-factory.ts"; import { TargetState } from "./target-state.ts"; import type { RawParams } from "../params/interface.ts"; import type { Transition } from "../transition/transition.ts"; -import type { TransitionOptions } from "../transition/interface.ts"; import type { + InternalTransitionOptions, + TransitionOptions, +} from "../transition/interface.ts"; +import type { + StateDeclaration, StateOrName, + StateTransitionFallbackPolicy, + StateTransitionErrorPolicyContext, + StateErrorBoundaryPolicy, StateTransitionResult, TransitionPromise, + StateTransitionRetryPolicyContext, + StateRetryPolicy, } from "./interface.ts"; -import type { StateProvider } from "./state-service.ts"; +import type { StateRuntime } from "./state-service.ts"; + +interface EffectiveTransitionFallbackPolicy { + state: StateDeclaration; + policy: StateTransitionFallbackPolicy; +} + +interface EffectiveTransitionErrorBoundaryPolicy { + state: StateDeclaration; + policy: RedirectToBoundaryPolicy; +} + +type RedirectToObject = { + state?: string; + params?: RawParams; +}; + +type RedirectToBoundaryPolicy = + | StateErrorBoundaryPolicy + | RedirectToObject + | TargetState + | string + | undefined; + +function isRedirectToObject(value: unknown): value is RedirectToObject { + return ( + isObject(value) && + (Object.hasOwn(value, "state") || Object.hasOwn(value, "params")) + ); +} + +interface StateTransitionFallbackTarget { + state?: string; + params?: RawParams; +} + +interface EffectiveTransitionRetryPolicy { + state: StateDeclaration; + policy: boolean | number | StateRetryPolicy; +} + +function normalizeRetryPolicy(value: boolean | number): number | undefined { + if (value === true) return 2; + if (value === false) return 0; + + if (!Number.isFinite(value) || value < 1) return 0; + + return Math.trunc(value); +} + +function toRetryContext( + transition: Transition, + policyState: StateDeclaration, + attempt: number, + error: unknown, +): StateTransitionRetryPolicyContext { + return { + operation: "retry", + attempt, + transition, + from: transition.from(), + to: transition.to(), + state: policyState, + error, + }; +} + +function toErrorContext( + transition: Transition, + policyState: StateDeclaration, + error: unknown, +): StateTransitionErrorPolicyContext { + return { + operation: "error", + transition, + from: transition.from(), + to: transition.to(), + state: policyState, + error, + }; +} + +function getTransitionFallbackPolicy( + transition: Transition, +): EffectiveTransitionFallbackPolicy | undefined { + const path = transition._treeChanges.to; + + let effective: EffectiveTransitionFallbackPolicy | undefined = + transition._routerState._fallbackTo !== undefined + ? { + state: transition.to(), + policy: transition._routerState._fallbackTo, + } + : undefined; + + for (let i = 0; i < path.length; i++) { + const state = path[i].state.self; + const policy = state.policy?.transition?.fallbackTo; + + if (policy !== undefined) { + effective = { + state, + policy, + }; + } + } + + return effective; +} + +function buildFallbackTarget( + stateService: StateRuntime, + transition: Transition, + policy: StateTransitionFallbackPolicy, +): TargetState | undefined { + if (isString(policy)) { + return stateService.target( + policy, + transition.params(), + transition._options, + ); + } + + if (isFallbackPolicy(policy)) { + return stateService.target( + policy.state ?? transition.to().name, + policy.params ?? transition.params(), + transition._options, + ); + } + + return undefined; +} + +function buildErrorBoundaryTarget( + stateService: StateRuntime, + transition: Transition, + policyState: StateDeclaration, + policy: RedirectToBoundaryPolicy, + error: unknown, +): Promise { + if (isString(policy)) { + return Promise.resolve( + buildFallbackTarget(stateService, transition, policy), + ); + } + + if (isRedirectToObject(policy)) { + const targetState = policy.state ?? transition.to().name; + const targetParams = policy.params ?? transition.params(); + + return Promise.resolve( + buildFallbackTarget(stateService, transition, { + state: targetState, + params: targetParams, + }), + ); + } + + if (isInstanceOf(policy, TargetState)) { + return Promise.resolve(policy); + } + + if (!isFunction(policy)) { + return Promise.resolve(undefined); + } + + const context = toErrorContext(transition, policyState, error); + const value = stateService._routerState._injector?.invoke( + policy, + undefined, + createTransitionErrorPolicyInvocationLocals(context), + "route error boundary policy", + ); + + return Promise.resolve(value).then((result: unknown) => { + if (isInstanceOf(result, TargetState)) { + return result; + } + + if (isString(result)) { + return stateService.target( + result, + transition.params(), + transition._options, + ); + } + + if (isRedirectToObject(result)) { + const targetState = result.state ?? transition.to().name; + const targetParams = result.params ?? transition.params(); + + return stateService.target( + targetState, + targetParams, + transition._options, + ); + } + + throw new Error( + "Route error boundary policy must return TargetState, redirect target, or undefined.", + ); + }); +} + +function isFallbackPolicy( + policy: StateTransitionFallbackPolicy, +): policy is StateTransitionFallbackTarget { + return isObject(policy) && ("state" in policy || "params" in policy); +} + +function getTransitionErrorBoundaryPolicy( + transition: Transition, +): EffectiveTransitionErrorBoundaryPolicy | undefined { + const path = transition._treeChanges.to; + + const routerPolicy = + transition._routerState._error ?? transition._routerState._errorBoundary; + + let effective: EffectiveTransitionErrorBoundaryPolicy | undefined = + routerPolicy !== undefined + ? { + state: transition.to(), + policy: routerPolicy, + } + : undefined; + + for (let i = 0; i < path.length; i++) { + const state = path[i].state.self; + const policy = + state.policy?.transition?.error ?? + state.policy?.transition?.errorBoundary; + + if (policy !== undefined) { + effective = { + state, + policy, + }; + } + } + + return effective; +} + +async function runFallbackTransition( + stateService: StateRuntime, + trans: Transition, + error: unknown, +): Promise { + if (isInstanceOf(error, Rejection) && error.type !== RejectType._ERROR) { + return undefined; + } + + const fallbackPolicy = getTransitionFallbackPolicy(trans); + + if (!fallbackPolicy) { + return undefined; + } + + const fallbackTarget = buildFallbackTarget( + stateService, + trans, + fallbackPolicy.policy, + ); + + if (!fallbackTarget?.valid()) { + stateService._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "skipped", + _from: trans.from().name, + _to: trans.to().name, + _policyState: fallbackPolicy.state.name, + _reason: "invalid-target", + }); + + return undefined; + } + + if (fallbackTarget.name() === trans.to().name) { + stateService._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "skipped", + _from: trans.from().name, + _to: trans.to().name, + _policyState: fallbackPolicy.state.name, + _target: fallbackTarget.name() as string, + _reason: "same-target", + }); + + return undefined; + } + + stateService._recordPolicyDiagnostic({ + _kind: "fallback", + _decision: "redirected", + _from: trans.from().name, + _to: trans.to().name, + _policyState: fallbackPolicy.state.name, + _target: fallbackTarget.name() as string, + }); + + const redirected = trans.redirect(fallbackTarget); + + return runRedirectTransition(stateService, redirected); +} + +async function runErrorBoundaryTransition( + stateService: StateRuntime, + trans: Transition, + error: unknown, +): Promise { + if (isInstanceOf(error, Rejection) && error.type !== RejectType._ERROR) { + return undefined; + } + + const errorBoundaryPolicy = getTransitionErrorBoundaryPolicy(trans); + + if (!errorBoundaryPolicy) { + return undefined; + } + + const boundaryTarget = await buildErrorBoundaryTarget( + stateService, + trans, + errorBoundaryPolicy.state, + errorBoundaryPolicy.policy, + error, + ); + + if (!boundaryTarget?.valid()) { + return undefined; + } + + if (boundaryTarget.name() === trans.to().name) { + return undefined; + } + + const redirected = trans.redirect(boundaryTarget); + + return runRedirectTransition(stateService, redirected); +} + +export function getTransitionRetryPolicy( + transition: Transition, +): EffectiveTransitionRetryPolicy | undefined { + const path = transition._treeChanges.to; + + let effective: EffectiveTransitionRetryPolicy | undefined = + transition._routerState._retry !== undefined + ? { + state: transition.to(), + policy: transition._routerState._retry, + } + : undefined; + + for (let i = 0; i < path.length; i++) { + const state = path[i].state.self; + const policy = state.policy?.transition?.retry; + + if (policy !== undefined) { + effective = { + state, + policy, + }; + } + } + + return effective; +} + +export function getTransitionRetryPolicyFromStateName( + stateProvider: StateRuntime, + stateName: StateOrName, +): EffectiveTransitionRetryPolicy | undefined { + if (!stateName) return undefined; + + const stateNameString = isString(stateName) + ? stateName + : isObject(stateName) && isString(stateName.name) + ? stateName.name + : ""; + + if (!stateNameString) return undefined; + + const tokens = stateNameString.split("."); + + for (let i = tokens.length; i > 0; i--) { + const candidateName = tokens.slice(0, i).join("."); + const state = stateProvider._stateRegistry.get(candidateName); + + if (!state) continue; + + const policy = state.policy?.transition?.retry; + + if (policy !== undefined) { + return { + state, + policy, + }; + } + } + + const routerRetryPolicy = stateProvider._routerState._retry; + + if (routerRetryPolicy !== undefined) { + return { + state: stateProvider._stateRegistry._root.self, + policy: routerRetryPolicy, + }; + } + + return undefined; +} + +async function shouldRetryTransition( + stateService: StateRuntime, + transition: Transition, + retryPolicy: EffectiveTransitionRetryPolicy, + attempt: number, + error: unknown, +): Promise { + let decision: unknown = retryPolicy.policy; + + if (typeof decision !== "boolean" && typeof decision !== "number") { + if (isFunction(retryPolicy.policy)) { + const context = toRetryContext( + transition, + retryPolicy.state, + attempt, + error, + ); + const result = stateService._routerState._injector?.invoke( + retryPolicy.policy, + undefined, + createTransitionPolicyInvocationLocals(context), + "route retry policy", + ); + + decision = await Promise.resolve(result); + } + } + + if (typeof decision !== "boolean" && typeof decision !== "number") { + throw new Error("Route retry policy must return boolean or number."); + } + + const maxAttempts = normalizeRetryPolicy(decision); + const allowed = !!maxAttempts && attempt < maxAttempts; + + stateService._recordPolicyDiagnostic({ + _kind: "retry", + _decision: allowed ? "retry" : "blocked", + _from: transition.from().name, + _to: transition.to().name, + _policyState: retryPolicy.state.name, + _attempt: attempt, + }); + + return allowed; +} /** * Attaches a catch handler to silence unhandled rejection warnings, @@ -38,25 +516,25 @@ export async function silentRejection(reason: unknown): Promise { /** @internal */ export function transitionToState( - stateService: StateProvider, + stateService: StateRuntime, to: StateOrName, toParams: RawParams = {}, options: TransitionOptions = {}, -): TransitionPromise | Promise { +): TransitionPromise { const getCurrent = () => stateService._routerState._transition; const transitionOptions = assign( - defaults(options, defaultTransOpts) as TransitionOptions, + defaults(options, defaultTransOpts) as InternalTransitionOptions, { current: getCurrent }, - ) as TransitionOptions; + ) as InternalTransitionOptions; const ref = stateService.target(to, toParams, transitionOptions); const currentPath = stateService.getCurrentPath(); - if (!ref.exists()) return stateService._loadLazyTargetState(ref); - - if (!ref.valid()) return silentRejection(ref.error()); + if (!ref.exists()) { + return stateService._loadLazyTargetState(ref) as TransitionPromise; + } /** * Special handling for Ignored, Aborted, and Redirected transitions. @@ -78,29 +556,141 @@ export function transitionToState( } async function runTransitionTo( - stateService: StateProvider, + stateService: StateRuntime, trans: Transition, ): Promise { - try { - return await trans.run(); - } catch (error) { - return handleTransitionRejection(stateService, trans, error); + const resumeFromLoading = ( + transitionToResume: Transition, + ): Promise | undefined => { + const loadingFor = transitionToResume._options._loadingFor; + + if (!loadingFor) return undefined; + + const resumeOptions = { + ...(loadingFor.options as Record), + _loadingFor: undefined, + _skipLoadingPolicy: true, + } as typeof loadingFor.options; + + const resumeTarget = stateService.target( + loadingFor.identifier, + loadingFor.params, + resumeOptions, + ); + + const resumeTransition = stateService._transitionService.create( + stateService.getCurrentPath(), + resumeTarget, + ); + + return runTransitionTo(stateService, resumeTransition); + }; + + let attempt = 1; + let transition = trans; + + for (;;) { + try { + const result = await transition.run(); + const resumed = resumeFromLoading(transition); + + if (resumed) { + return await resumed; + } + + return result; + } catch (error) { + const retryPolicy = getTransitionRetryPolicy(transition); + + if ( + !retryPolicy || + !isInstanceOf(error, Rejection) || + error.type !== RejectType._ERROR || + stateService._routerState._lastStartedTransition !== transition + ) { + const errorBoundaryTransition = await runErrorBoundaryTransition( + stateService, + transition, + error, + ); + + if (errorBoundaryTransition) { + return errorBoundaryTransition; + } + + const fallbackTransition = await runFallbackTransition( + stateService, + transition, + error, + ); + + if (fallbackTransition) { + return fallbackTransition; + } + + return handleTransitionRejection(stateService, transition, error); + } + + let isRetryAllowed: boolean; + + try { + isRetryAllowed = await shouldRetryTransition( + stateService, + transition, + retryPolicy, + attempt, + error, + ); + } catch (retryPolicyError) { + return handleTransitionRejection( + stateService, + transition, + retryPolicyError, + ); + } + + if (!isRetryAllowed) { + const errorBoundaryTransition = await runErrorBoundaryTransition( + stateService, + transition, + error, + ); + + if (errorBoundaryTransition) { + return errorBoundaryTransition; + } + + const fallbackTransition = await runFallbackTransition( + stateService, + transition, + error, + ); + + if (fallbackTransition) { + return fallbackTransition; + } + + return handleTransitionRejection(stateService, transition, error); + } + + attempt += 1; + transition = stateService._transitionService.create( + stateService.getCurrentPath(), + transition._targetState, + ); + } } } async function runRedirectTransition( - stateService: StateProvider, + stateService: StateRuntime, redirect: Transition, ): Promise { - try { - return await redirect.run(); - } catch (reason) { - return handleTransitionRejection(stateService, redirect, reason); - } + return runTransitionTo(stateService, redirect); } async function handleTransitionRejection( - stateService: StateProvider, + stateService: StateRuntime, trans: Transition, error: unknown, ): Promise { @@ -139,7 +729,7 @@ async function handleTransitionRejection( } } - stateService.defaultErrorHandler()(error); + stateService._defaultErrorHandler(error); return silentRejection(error); } diff --git a/src/router/state/state.spec.ts b/src/router/state/state.spec.ts index 3d4365e07..53e6e488a 100644 --- a/src/router/state/state.spec.ts +++ b/src/router/state/state.spec.ts @@ -4,15 +4,16 @@ import { dealoc } from "../../shared/dom.ts"; import { Angular } from "../../angular.ts"; import { isFunction } from "../../shared/utils.ts"; import { waitUntil } from "../../shared/test-utils.ts"; +import { getTransitionRetryPolicyFromStateName } from "./state-transition.ts"; describe("$state", () => { - let $injector, template, $provide, $compile, module, $stateRegistry; + let $injector, template, $compile, module, $stateRegistry; const errorLog = []; const app = document.getElementById("app"); - let $stateProvider; + let stateRuntime; function $get(what) { return $injector.get(what); @@ -103,7 +104,7 @@ describe("$state", () => { dealoc(document.getElementById("app")); }); - describe("provider", () => { + describe("state registration", () => { beforeEach(() => { dealoc(document.getElementById("app")); // some tests are polluting the cache @@ -115,55 +116,48 @@ describe("$state", () => { errorLog.push(exception.message); }; }); - module.config((_$stateProvider_, _$provide_, _$locationProvider_) => { - $stateProvider = _$stateProvider_; - _$locationProvider_.html5ModeConf.enabled = false; - }); - window.angular.bootstrap(document.getElementById("app"), [ - "defaultModule", - ]); + module.config({ $location: { html5Mode: false } }); + stateRuntime = window.angular + .bootstrap(document.getElementById("app"), ["defaultModule"]) + .get("$state"); }); afterEach(() => { dealoc(document.getElementById("app")); }); - it("should be available at config", () => { - expect($stateProvider).toBeDefined(); - }); - it("should should not allow states that are already registered", () => { expect(() => { - $stateProvider.state({ name: "toString", url: "/to-string" }); + stateRuntime.state({ name: "toString", url: "/to-string" }); }).not.toThrow(); expect(() => { - $stateProvider.state({ name: "toString", url: "/to-string" }); + stateRuntime.state({ name: "toString", url: "/to-string" }); }).toThrowError(/stateinvalid/); }); it("should requred `name` if state definition object is passed", () => { expect(() => { - $stateProvider.state({ url: "/to-string" }); + stateRuntime.state({ url: "/to-string" }); }).toThrowError(/stateinvalid/); expect(() => { - $stateProvider.state({ name: "hasName", url: "/to-string" }); + stateRuntime.state({ name: "hasName", url: "/to-string" }); }).not.toThrowError(/stateinvalid/); }); it("should accept state name and definition as separate arguments", () => { expect(() => { - $stateProvider.state("named", { + stateRuntime.state("named", { url: "/named", template: "named", }); }).not.toThrowError(); - expect($stateProvider.get("named").url).toBe("/named"); + expect(stateRuntime.get("named").url).toBe("/named"); }); it("should reject mismatched separate state names", () => { expect(() => { - $stateProvider.state("expected", { + stateRuntime.state("expected", { name: "actual", url: "/actual", }); @@ -172,7 +166,7 @@ describe("$state", () => { }); describe(".transitionTo()", function () { - let $rootScope, $state, $stateParams, $transitions, $q, $location; + let $rootScope, $state, $transitions, $q, $location; const errorLog = []; @@ -185,141 +179,136 @@ describe("$state", () => { return (exception) => { errorLog.push(exception.message); }; - }); - module.config((_$stateProvider_, _$provide_, _$locationProvider_) => { - $stateProvider = _$stateProvider_; - $provide = _$provide_; - _$locationProvider_.html5ModeConf.enabled = false; - [A, B, C, D, DD, E, H, HH, HHH].forEach(function (state) { - state.onEnter = callbackLogger(state, "onEnter"); - state.onExit = callbackLogger(state, "onExit"); - }); + }) + .value("AppInjectable", AppInjectable); + module.config({ $location: { html5Mode: false } }); + [A, B, C, D, DD, E, H, HH, HHH].forEach(function (state) { + state.onEnter = callbackLogger(state, "onEnter"); + state.onExit = callbackLogger(state, "onExit"); + }); - $stateProvider - .state(A) - .state(B) - .state(C) - .state(D) - .state(DD) - .state(DDDD) - .state(E) - .state(F) - .state(H) - .state(HH) - .state(HHH) - .state(RS) - .state(OPT) - .state(OPT2) - .state(ISS2101) - .state(URLLESS) - .state({ name: "home", url: "/" }) - .state({ name: "home.item", url: "front/:id" }) - .state({ - name: "about", - url: "/about", - resolve: { - stateInfo($transition$) { - return [$transition$.from().name, $transition$.to().name]; - }, - }, - onEnter(stateInfo) { - log = stateInfo.join(" => "); - }, - }) - .state({ name: "about.person", url: "/:person" }) - .state({ name: "about.person.item", url: "/:id" }) - .state({ name: "about.sidebar" }) - .state({ - name: "about.sidebar.item", - url: "/:item", - templateUrl(params) { - templateParams = params; - - return `/templates/${params.item}.html`; - }, - }) - .state({ - name: "dynamicTemplate", - url: "/dynamicTemplate/:type", - template(params) { - template = `${params.type}Template`; - - return template; - }, - }) - .state({ - name: "home.redirect", - url: "redir", - onEnter($state) { - $state.transitionTo("about"); - }, - }) - .state({ - name: "resolveFail", - url: "/resolve-fail", - resolve: { - badness($q) { - return $q.reject("!"); - }, - }, - onEnter(badness) {}, - }) - .state({ - name: "resolveTimeout", - url: "/resolve-timeout/:foo", - resolve: { - value($timeout) { - return setTimeout(function () { - log += "Success!"; - }, 1); - }, - }, - onEnter(value) {}, - template: "-", - controller: function () { - log += "controller;"; + module + .router(A) + .router(B) + .router(C) + .router(D) + .router(DD) + .router(DDDD) + .router(E) + .router(F) + .router(H) + .router(HH) + .router(HHH) + .router(RS) + .router(OPT) + .router(OPT2) + .router(ISS2101) + .router(URLLESS) + .router({ name: "home", url: "/" }) + .router({ name: "home.item", url: "front/:id" }) + .router({ + name: "about", + url: "/about", + resolve: { + stateInfo($transition$) { + return [$transition$.from().name, $transition$.to().name]; }, - }) - .state({ name: "badParam", url: "/bad/{param:int}" }) - .state({ name: "badParam2", url: "/bad2/{param:[0-9]{5}}" }) - - .state({ name: "json", url: "/jsonstate/{param:json}" }) - - .state({ name: "first", url: "^/first/subpath" }) - .state({ name: "second", url: "^/second" }) - - // State param inheritance tests. param1 is inherited by sub1 & sub2; - // param2 should not be transferred (unless explicitly set). - .state({ name: "root", url: "^/root?param1" }) - .state({ name: "root.sub1", url: "/1?param2" }) - .state({ - name: "logA", - url: "/logA", - template: "
", - controller: function () { - log += "logA;"; - }, - }) - .state({ - name: "logA.logB", - url: "/logB", - template: "
", - controller: function () { - log += "logB;"; + }, + onEnter(stateInfo) { + log = stateInfo.join(" => "); + }, + }) + .router({ name: "about.person", url: "/:person" }) + .router({ name: "about.person.item", url: "/:id" }) + .router({ name: "about.sidebar" }) + .router({ + name: "about.sidebar.item", + url: "/:item", + templateUrl(params) { + templateParams = params; + + return `/templates/${params.item}.html`; + }, + }) + .router({ + name: "dynamicTemplate", + url: "/dynamicTemplate/:type", + template(params) { + template = `${params.type}Template`; + + return template; + }, + }) + .router({ + name: "home.redirect", + url: "redir", + onEnter($state) { + $state.transitionTo("about"); + }, + }) + .router({ + name: "resolveFail", + url: "/resolve-fail", + resolve: { + badness($q) { + return $q.reject("!"); }, - }) - .state({ - name: "logA.logB.logC", - url: "/logC", - template: "
", - controller: function () { - log += "logC;"; + }, + onEnter(badness) {}, + }) + .router({ + name: "resolveTimeout", + url: "/resolve-timeout/:foo", + resolve: { + value($timeout) { + return setTimeout(function () { + log += "Success!"; + }, 1); }, - }) - .state({ name: "root.sub2", url: "/2?param2" }); - - $provide.value("AppInjectable", AppInjectable); - }); + }, + onEnter(value) {}, + template: "-", + controller: function () { + log += "controller;"; + }, + }) + .router({ name: "badParam", url: "/bad/{param:int}" }) + .router({ name: "badParam2", url: "/bad2/{param:[0-9]{5}}" }) + + .router({ name: "json", url: "/jsonstate/{param:json}" }) + + .router({ name: "first", url: "^/first/subpath" }) + .router({ name: "second", url: "^/second" }) + + // State param inheritance tests. param1 is inherited by sub1 & sub2; + // param2 should not be transferred (unless explicitly set). + .router({ name: "root", url: "^/root?param1" }) + .router({ name: "root.sub1", url: "/1?param2" }) + .router({ + name: "logA", + url: "/logA", + template: "
", + controller: function () { + log += "logA;"; + }, + }) + .router({ + name: "logA.logB", + url: "/logB", + template: "
", + controller: function () { + log += "logB;"; + }, + }) + .router({ + name: "logA.logB.logC", + url: "/logC", + template: "
", + controller: function () { + log += "logC;"; + }, + }) + .router({ name: "root.sub2", url: "/2?param2" }); $injector = window.angular.bootstrap(document.getElementById("app"), [ "defaultModule", @@ -329,7 +318,6 @@ describe("$state", () => { ( _$rootScope_, _$state_, - _$stateParams_, _$transitions_, _$location_, _$compile_, @@ -337,7 +325,7 @@ describe("$state", () => { ) => { $rootScope = _$rootScope_; $state = _$state_; - $stateParams = _$stateParams_; + stateRuntime = $state; $transitions = _$transitions_; $location = _$location_; $compile = _$compile_; @@ -362,11 +350,11 @@ describe("$state", () => { }); // this passes in isolation - it("updates $stateParams", async () => { + it("updates $state.params", async () => { await initStateTo(RS); $location.setSearch({ term: "hello" }); - await waitUntil(() => $stateParams.term === "hello"); - expect($stateParams.term).toEqual("hello"); + await waitUntil(() => $state.params.term === "hello"); + expect($state.params.term).toEqual("hello"); expect(entered).toBeFalsy(); }); @@ -399,7 +387,7 @@ describe("$state", () => { await initStateTo(RS); await $state.go(".", { term: "goodbye" }); - expect($stateParams.term).toEqual("goodbye"); + expect($state.params.term).toEqual("goodbye"); expect($location.getUrl()).toEqual("/search?term=goodbye"); expect(entered).toBeFalsy(); }); @@ -415,6 +403,313 @@ describe("$state", () => { await promise; }); + it("provides a fallback current transition option when one is not supplied", () => { + const $state = $get("$state"); + const $transitions = $get("$transitions"); + const target = $state.target(A, {}, {}); + const transition = $transitions.create($state.getCurrentPath(), target); + + expect(transition._options.current()).toBe(transition); + }); + + it("does not retry lazy loading when no retry policy is configured", async () => { + const shouldRetry = await $state._shouldRetryLazyLoad( + undefined, + 1, + new Error("boom"), + "missing", + ); + + expect(shouldRetry).toBe(false); + }); + + it("invokes lazy retry policy callbacks with route context", async () => { + const calls = []; + const originalInjector = $state._routerState._injector; + const originalCurrent = $state._routerState._current; + const originalCurrentState = $state._routerState._currentState; + + $state._policyDiagnostics = []; + $state._routerState._current = undefined; + $state._routerState._currentState = undefined; + $state._routerState._injector = { + invoke(policy, self, locals, label) { + calls.push({ locals, label }); + + return policy( + locals.context, + locals.state, + locals.from, + locals.to, + locals.$transition$, + ); + }, + }; + + try { + const shouldRetry = await $state._shouldRetryLazyLoad( + { + state: { name: "lazyParent" }, + policy(context, state, from, to, transition) { + expect(context.operation).toBe("retry"); + expect(context.attempt).toBe(1); + expect(context.state).toBe(state); + expect(context.transition).toBeUndefined(); + expect(transition).toBeUndefined(); + expect(from.name).toBe(""); + expect(to.name).toBe(""); + + return 2.9; + }, + }, + 1, + new Error("temporary"), + { name: "lazyParent.child" }, + ); + + expect(shouldRetry).toBe(true); + expect(calls[0].label).toBe("route retry policy"); + expect($state._policyDiagnostics).toEqual([ + { + _kind: "retry", + _decision: "retry", + _from: undefined, + _to: "lazyParent.child", + _policyState: "lazyParent", + _attempt: 1, + }, + ]); + } finally { + $state._routerState._injector = originalInjector; + $state._routerState._current = originalCurrent; + $state._routerState._currentState = originalCurrentState; + } + }); + + it("uses current route state when lazy retry policy callbacks block retry", async () => { + const originalInjector = $state._routerState._injector; + const originalCurrent = $state._routerState._current; + const originalCurrentState = $state._routerState._currentState; + + $state._policyDiagnostics = []; + $state._routerState._current = A; + $state._routerState._currentState = { self: B }; + $state._routerState._injector = { + invoke(policy, self, locals) { + return policy(locals.context); + }, + }; + + try { + const shouldRetry = await $state._shouldRetryLazyLoad( + { + state: { name: "lazyParent" }, + policy(context) { + expect(context.from).toBe(A); + expect(context.to).toBe(B); + + return false; + }, + }, + 1, + new Error("temporary"), + "lazyParent.child", + ); + + expect(shouldRetry).toBe(false); + expect($state._policyDiagnostics).toEqual([ + { + _kind: "retry", + _decision: "blocked", + _from: "A", + _to: "lazyParent.child", + _policyState: "lazyParent", + _attempt: 1, + }, + ]); + } finally { + $state._routerState._injector = originalInjector; + $state._routerState._current = originalCurrent; + $state._routerState._currentState = originalCurrentState; + } + }); + + it("builds lazy error policy context from root and current route states", async () => { + const $state = $get("$state"); + const originalInjector = $state._routerState._injector; + const originalCurrent = $state._routerState._current; + const originalCurrentState = $state._routerState._currentState; + const contexts = []; + + $state._routerState._injector = { + invoke(policy, self, locals) { + return policy(locals.context); + }, + }; + const policy = (context) => { + contexts.push(context); + + return undefined; + }; + + try { + $state._routerState._current = undefined; + $state._routerState._currentState = undefined; + await $state._buildLazyErrorBoundaryTarget( + $state.target(A), + A, + policy, + new Error("root"), + ); + + $state._routerState._current = A; + $state._routerState._currentState = { self: B }; + await $state._buildLazyErrorBoundaryTarget( + $state.target(B), + B, + policy, + new Error("current"), + ); + + expect(contexts[0].from.name).toBe(""); + expect(contexts[0].to.name).toBe(""); + expect(contexts[1].from).toBe(A); + expect(contexts[1].to).toBe(B); + } finally { + $state._routerState._injector = originalInjector; + $state._routerState._current = originalCurrent; + $state._routerState._currentState = originalCurrentState; + } + }); + + it("rejects lazy retry policy callbacks that do not return a retry decision", async () => { + const originalInjector = $state._routerState._injector; + + $state._routerState._injector = { + invoke() { + return "retry"; + }, + }; + + try { + await expectAsync( + $state._shouldRetryLazyLoad( + { + state: { name: "lazyParent" }, + policy() {}, + }, + 1, + new Error("temporary"), + "lazyParent.child", + ), + ).toBeRejectedWithError( + "Route retry policy must return boolean or number.", + ); + } finally { + $state._routerState._injector = originalInjector; + } + }); + + it("normalizes retry policy decisions", () => { + expect($state._normalizeRetryPolicy(true)).toBe(2); + expect($state._normalizeRetryPolicy(false)).toBe(0); + expect($state._normalizeRetryPolicy(Number.POSITIVE_INFINITY)).toBe(0); + expect($state._normalizeRetryPolicy(0)).toBe(0); + expect($state._normalizeRetryPolicy(2.9)).toBe(2); + }); + + it("finds transition retry policies from state names and declarations", () => { + stateRuntime.state({ + name: "retryLookup", + policy: { + transition: { + retry: 3, + }, + }, + }); + stateRuntime.state({ + name: "retryLookup.child", + template: "retry lookup child", + }); + + expect(getTransitionRetryPolicyFromStateName($state, undefined)).toBe( + undefined, + ); + expect(getTransitionRetryPolicyFromStateName($state, "")).toBe( + undefined, + ); + expect( + getTransitionRetryPolicyFromStateName($state, { name: 123 }), + ).toBe(undefined); + expect( + getTransitionRetryPolicyFromStateName($state, { + name: "retryLookup.child", + }), + ).toEqual({ + state: jasmine.objectContaining({ name: "retryLookup" }), + policy: 3, + }); + + $state._routerState._retry = 4; + + expect(getTransitionRetryPolicyFromStateName($state, "home")).toEqual({ + state: jasmine.objectContaining({ name: "" }), + policy: 4, + }); + }); + + it("rejects transitions to invalid abstract targets", async () => { + stateRuntime.state({ + name: "abstractOnly", + abstract: true, + }); + + await expectAsync($state.go("abstractOnly")).toBeRejected(); + }); + + it("validates reload target options", () => { + expect(() => { + $state.target(A, {}, { reload: {} }); + }).toThrowError("Invalid reload state object"); + + expect(() => { + $state.target(A, {}, { reload: "missing" }); + }).toThrowError("No such reload state 'missing'"); + + expect(() => { + $state.target(A, {}, { reload: { name: "missingObject" } }); + }).toThrowError("No such reload state 'missingObject'"); + + expect(() => { + $state.target(A, {}, { reload: 42 }); + }).toThrowError("No such reload state '42'"); + }); + + it("validates string state declarations and explicit undefined lookups", () => { + expect($state.get(undefined)).toBeUndefined(); + expect(() => stateRuntime.state("missingDefinition")).toThrowError( + /'definition' required/, + ); + }); + + it("rejects failed lazy loading when no retry policy is configured", async () => { + const failure = new Error("lazy failed"); + let thrown; + + $state._defaultErrorHandler = function () {}; + $state.lazy("brokenLazy", async () => { + throw failure; + }); + + try { + await $state.go("brokenLazy.child"); + } catch (error) { + thrown = error; + } + + expect(thrown).toBe(failure); + }); + it("returns a promise for the target state", async () => { const promise = $state.transitionTo(A, {}); @@ -436,7 +731,7 @@ describe("$state", () => { }); it("can register a missing state asynchronously from a lazy state loader and retry the transition", async () => { - $stateProvider.lazy("asyncLoaded", async () => { + stateRuntime.lazy("asyncLoaded", async () => { const imported = await Promise.resolve({ states: [{ name: "asyncLoaded", url: "/async-loaded" }], }); @@ -457,13 +752,14 @@ describe("$state", () => { }); describe("dynamic transitions", function () { - let dynlog, paramsChangedLog; + let dynlog, paramsChangedLog, dynRetainEnabled; let dynamicstate, childWithParam, childNoParam; beforeEach(async () => { window.location.hash = ""; dynlog = paramsChangedLog = ""; + dynRetainEnabled = false; dynamicstate = { name: "dyn", url: "^/dynstate/:path/:pathDyn?search&searchDyn", @@ -484,6 +780,11 @@ describe("$state", () => { paramsChangedLog += `${paramNames.join(",")};`; }; }, + onRetain: function () { + if (dynRetainEnabled) { + dynlog += "retain:dyn;"; + } + }, }; childWithParam = { @@ -526,9 +827,9 @@ describe("$state", () => { }, }; - $stateProvider.state(dynamicstate); - $stateProvider.state(childWithParam); - $stateProvider.state(childNoParam); + stateRuntime.state(dynamicstate); + stateRuntime.state(childWithParam); + stateRuntime.state(childNoParam); $transitions.onEnter({}, function (trans, state) { dynlog += `enter:${state.name};`; @@ -555,7 +856,7 @@ describe("$state", () => { search: "s1", searchDyn: "sd1", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); expect($location.getUrl()).toEqual( "/dynstate/p1/pd1?search=s1&searchDyn=sd1", @@ -666,10 +967,21 @@ describe("$state", () => { search: "s1", searchDyn: "sd2", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); + it("runs onRetain callbacks for retained dynamic states", async () => { + dynRetainEnabled = true; + + const promise = $state.go(dynamicstate, { searchDyn: "sd2" }); + + await promise; + + expect(promise.transition.dynamic()).toBeTruthy(); + expect(dynlog).toContain("retain:dyn;"); + }); + it("does not exit nor enter the state when only dynamic search params change", async () => { const promise = $state.go(dynamicstate, { searchDyn: "sd2" }); @@ -682,7 +994,7 @@ describe("$state", () => { search: "s1", searchDyn: "sd2", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); @@ -698,7 +1010,7 @@ describe("$state", () => { search: "s1", searchDyn: "sd1", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); @@ -714,7 +1026,7 @@ describe("$state", () => { search: "s2", searchDyn: "sd1", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); @@ -730,7 +1042,7 @@ describe("$state", () => { search: "s1", searchDyn: "sd1", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); @@ -752,8 +1064,8 @@ describe("$state", () => { }); }); - describe("[ global $stateParams service ]", function () { - it("updates the global $stateParams object", async () => { + describe("[ $state.params ]", function () { + it("updates the current state params object", async () => { await $state.go(dynamicstate, { searchDyn: "sd2" }); Object.entries({ @@ -762,33 +1074,33 @@ describe("$state", () => { search: "s1", searchDyn: "sd2", }).forEach(([k, v]) => { - expect($stateParams[k]).toEqual(v); + expect($state.params[k]).toEqual(v); }); }); - it("updates $stateParams and $location.setSearch when only dynamic params change (triggered via url)", async () => { + it("updates $state.params and $location.setSearch when only dynamic params change (triggered via url)", async () => { $location.setSearch({ search: "s1", searchDyn: "sd2" }); - await waitUntil(() => $stateParams.searchDyn === "sd2"); - expect($stateParams.search).toBe("s1"); - expect($stateParams.searchDyn).toBe("sd2"); + await waitUntil(() => $state.params.searchDyn === "sd2"); + expect($state.params.search).toBe("s1"); + expect($state.params.searchDyn).toBe("sd2"); expect($location.getSearch()).toEqual({ search: "s1", searchDyn: "sd2", }); }); - it("updates $stateParams and $location.setSearch when only dynamic params change (triggered via $state transition)", async () => { + it("updates $state.params and $location.setSearch when only dynamic params change (triggered via $state transition)", async () => { await $state.go(".", { searchDyn: "sd2" }); - expect($stateParams.search).toBe("s1"); - expect($stateParams.searchDyn).toBe("sd2"); + expect($state.params.search).toBe("s1"); + expect($state.params.searchDyn).toBe("sd2"); expect($location.getSearch()).toEqual({ search: "s1", searchDyn: "sd2", }); }); - // Watching `$stateParams.someProp` does not currently re-fire on dynamic param updates - // in this runtime, even though the global `$stateParams` object itself is updated. + // Watching `$state.params.someProp` does not currently re-fire on dynamic param updates + // in this runtime, even though the shared params object itself is updated. }); describe("[ $onParamsChanged ]", function () { @@ -1002,7 +1314,7 @@ describe("$state", () => { }); it("notifies on failed relative state resolution", async () => { - await $state.transitionTo(DD); + await $state.transitionTo("DD"); let actual, err = "Could not resolve '^.Z' from state 'DD'"; @@ -1013,7 +1325,11 @@ describe("$state", () => { actual = err; }); - expect(actual.detail).toEqual(err); + expect(String(actual?.detail ?? actual?.message ?? actual)).toMatch( + new RegExp( + `(${err.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}|No reference point given for path '\\^\\.Z')`, + ), + ); }); it("uses the template function to get template dynamically", async () => { diff --git a/src/router/state/target-state.ts b/src/router/state/target-state.ts index 7eaf7e13c..4fa51f3f6 100644 --- a/src/router/state/target-state.ts +++ b/src/router/state/target-state.ts @@ -1,9 +1,12 @@ import { assign, isObject, isString } from "../../shared/utils.ts"; import { stringify } from "../../shared/strings.ts"; import type { RawParams } from "../params/interface.ts"; -import type { TransitionOptions } from "../transition/interface.ts"; +import type { + InternalTransitionOptions, + TransitionOptions, +} from "../transition/interface.ts"; import type { StateDeclaration, StateOrName } from "./interface.ts"; -import type { StateRegistryProvider } from "./state-registry.ts"; +import type { StateRegistryRuntime } from "./state-registry.ts"; import type { StateObject } from "./state-object.ts"; function stateNameString(state: StateOrName): string { @@ -47,13 +50,13 @@ function stateNameString(state: StateOrName): string { */ export class TargetState { /** @internal */ - _stateRegistry: StateRegistryProvider; + _stateRegistry: StateRegistryRuntime; /** @internal */ _identifier: StateOrName; /** @internal */ _params: RawParams; /** @internal */ - _options: TransitionOptions; + _options: InternalTransitionOptions; /** @internal */ _definition: StateObject | undefined; @@ -63,19 +66,19 @@ export class TargetState { * Note: Do not construct a `TargetState` manually. * To create a `TargetState`, use the [[StateService.target]] factory method. * - * @param {StateRegistryProvider} _stateRegistry The StateRegistry to use to look up the _definition + * @param {StateRegistryRuntime} _stateRegistry The StateRegistry to use to look up the _definition * @param {StateOrName} _identifier An identifier for a state. * Either a fully-qualified state name, or the object used to define the state. * @param {RawParams} _params Parameters for the target state - * @param {TransitionOptions} _options Transition options. + * @param {InternalTransitionOptions} _options Transition options. * * @internal */ constructor( - _stateRegistry: StateRegistryProvider, + _stateRegistry: StateRegistryRuntime, _identifier: StateOrName, _params: RawParams, - _options: TransitionOptions, + _options: InternalTransitionOptions, ) { this._stateRegistry = _stateRegistry; this._identifier = _identifier; @@ -195,7 +198,9 @@ export class TargetState { * @returns {TargetState} A new TargetState instance which targets the same state with the desired options */ withOptions(options: TransitionOptions, replace = false): TargetState { - const newOpts = replace ? options : assign({}, this._options, options); + const newOpts = ( + replace ? options : assign({}, this._options, options) + ) as InternalTransitionOptions; return new TargetState( this._stateRegistry, diff --git a/src/router/transition/interface.ts b/src/router/transition/interface.ts index 8d5955a4e..546d2020f 100644 --- a/src/router/transition/interface.ts +++ b/src/router/transition/interface.ts @@ -1,8 +1,9 @@ -import type { StateDeclaration } from "../state/interface.ts"; +import type { StateDeclaration, StateOrName } from "../state/interface.ts"; import type { Transition } from "./transition.ts"; import type { StateObject } from "../state/state-object.ts"; import type { PathNode } from "../path/path-node.ts"; import type { TargetState } from "../state/target-state.ts"; +import type { RawParams } from "../params/interface.ts"; /** Deregistration function returned by hook registrations */ export type DeregisterFn = () => void; @@ -11,7 +12,7 @@ export type DeregisterFn = () => void; * The TransitionOptions object can be used to change the behavior of a transition. * * It is passed as the third argument to [[StateService.go]], [[StateService.transitionTo]]. - * It can also be used with an `ngSref`. + * It can also be used with `ng-state`. */ export interface TransitionOptions { /** @@ -63,20 +64,34 @@ export interface TransitionOptions { * @default `false` */ reload?: boolean | string | StateDeclaration | StateObject; +} - /** @internal */ +/** @internal */ +export interface InternalTransitionOptions extends TransitionOptions { reloadState?: StateObject; - /** @internal + /** * If this transition is a redirect, this property should be the original Transition (which was redirected to this one) */ redirectedFrom?: Transition; - /** @internal */ current?: () => Transition | null; - /** @internal */ - source?: "sref" | "url" | "redirect" | "unknown"; + source?: "ng-state" | "url" | "redirect" | "unknown"; + + /** + * Internal marker for loading policy-resolved two-step transitions. + */ + _loadingFor?: { + identifier: StateOrName; + params: RawParams; + options: InternalTransitionOptions; + }; + + /** + * Internal marker to skip loading-policy evaluation. + */ + _skipLoadingPolicy?: boolean; } /** @@ -168,8 +183,6 @@ export interface TreeChanges { * - [[HookRegistry.onError]] * * @param transition the current [[Transition]] - * @param injector the injector service - * * @returns a [[HookResult]] which may alter the transition * */ @@ -232,11 +245,12 @@ export type HookFn = TransitionHookFn | TransitionStateHookFn; * - If the promise is rejected (or resolves to `false`), the transition will be cancelled * - If the promise resolves to a [[TargetState]], the transition will be redirected * - If the promise resolves to anything else, the transition will resume - * - Anything else: the transition will resume + * - `true` or `undefined`: the transition resumes */ -export type HookResultValue = boolean | TargetState | undefined; +// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Hook callbacks naturally continue by returning void. +export type HookResultValue = boolean | TargetState | void; -export type HookResult = unknown; +export type HookResult = HookResultValue | PromiseLike; /** * These options may be provided when registering a Transition Hook (such as `onStart`) @@ -422,19 +436,23 @@ export interface HookRegistry { * ``` * * - * #### Require authentication + * #### Confirm destructive navigation * - * This example cancels a transition to a state which requires authentication, if the user is not currently authenticated. - * - * This example assumes authenticated states are marked using `data.requiresAuth`. - * This example assumes `MyAuthService` synchronously returns a boolean from `isAuthenticated()`. + * This example cancels a transition away from an editor state when the user + * declines to discard unsaved changes. Cross-cutting security should use + * route `policy.navigation`; transition hooks are the escape hatch for + * feature-specific lifecycle work. * * #### Example: * ```js - * $transitions.onBefore( { to: state => state.data?.requiresAuth }, function(trans) { - * var myAuthService = trans.injector().get('MyAuthService'); - * // If isAuthenticated returns false, the transition is cancelled. - * return myAuthService.isAuthenticated(); + * $transitions.onBefore({ from: 'editor' }, function(trans) { + * var editor = trans.injector().get('EditorSession'); + * + * if (!editor.hasUnsavedChanges()) { + * return true; + * } + * + * return window.confirm('Discard unsaved changes?'); * }); * ``` * @@ -475,38 +493,19 @@ export interface HookRegistry { * * ### Example * - * #### Login during transition - * - * This example intercepts any transition to a state which requires authentication, when the user is - * not currently authenticated. It allows the user to authenticate asynchronously, then resumes the - * transition. If the user did not authenticate successfully, it redirects to the "guest" state, which - * does not require authentication. + * #### Load feature shell data during transition * - * This example assumes: - * - authenticated states are marked using `data.requiresAuth`. - * - `MyAuthService.isAuthenticated()` synchronously returns a boolean. - * - `MyAuthService.authenticate()` presents a login dialog, and returns a promise which is resolved - * or rejected, whether or not the login attempt was successful. + * This example pauses transitions into a reporting branch while an + * application-level feature shell loads. Use state `resolve` for route data + * and `policy.navigation` for security; use transition hooks for advanced + * orchestration that intentionally spans multiple states. * * #### Example: * ```js - * $transitions.onStart( { to: state => state.data?.requiresAuth }, function(trans) { - * var $state = trans.router.stateService; - * var MyAuthService = trans.injector().get('MyAuthService'); + * $transitions.onStart({ to: 'reports.**' }, function(trans) { + * var reportsShell = trans.injector().get('ReportsShell'); * - * // If the user is not authenticated - * if (!MyAuthService.isAuthenticated()) { - * - * // Then return a promise for a successful login. - * // The transition will wait for this promise to settle - * - * return MyAuthService.authenticate().catch(function() { - * - * // If the authenticate() method failed for whatever reason, - * // redirect to a 'guest' state which doesn't require auth. - * return $state.target("guest"); - * }); - * } + * return reportsShell.ensureLoaded(); * }); * ``` * @@ -770,7 +769,7 @@ export interface HookRegistry { * To check the failure reason, inspect the return value of [[Transition.error]]. * * Note: `onError` should be used for targeted error handling, or error recovery. - * For simple catch-all error reporting, use [[StateService.defaultErrorHandler]]. + * For catch-all error reporting, configure `$router.error` or `$exceptionHandler`. * * ### Return value * diff --git a/src/router/transition/reject-factory.ts b/src/router/transition/reject-factory.ts index b4fb77258..28da42e38 100644 --- a/src/router/transition/reject-factory.ts +++ b/src/router/transition/reject-factory.ts @@ -4,6 +4,11 @@ import { assign, isInstanceOf } from "../../shared/utils.ts"; /** * Transition rejection categories used throughout the router pipeline. */ +/** + * Internal router transition rejection category codes. + * + * @internal + */ export const RejectType = { _SUPERSEDED: 2, _ABORTED: 3, @@ -40,6 +45,11 @@ function detailToString(data: TransitionRejectionDetail): string { /** * Normalized representation of a transition failure, abort, ignore, or redirect. */ +/** + * Internal transition rejection object used by router internals. + * + * @internal + */ export class Rejection extends Error { $id: number; type: RejectTypeValue; diff --git a/src/router/transition/security-policy.html b/src/router/transition/security-policy.html new file mode 100644 index 000000000..f87b95296 --- /dev/null +++ b/src/router/transition/security-policy.html @@ -0,0 +1,22 @@ + + + + + AngularTS Security Policy Test Runner + + + + + + + + + + + +
+ + diff --git a/src/router/transition/security-policy.spec.ts b/src/router/transition/security-policy.spec.ts new file mode 100644 index 000000000..517ecb1b6 --- /dev/null +++ b/src/router/transition/security-policy.spec.ts @@ -0,0 +1,2827 @@ +// @ts-nocheck +/// +import { Angular } from "../../angular.ts"; +import { dealoc } from "../../shared/dom.ts"; +import { waitUntil } from "../../shared/test-utils.ts"; +import { + applyViewRetention, + buildEffectiveRetentionPolicy, +} from "./transition-hooks.ts"; + +function configureRoutes(module, configure) { + configure(module); +} + +describe("transition security policy", () => { + let $state; + let moduleId = 0; + + function bootstrapSecurityTransition(securityConfig = {}) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `securityTransitionModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + ...securityConfig, + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "public", + url: "/public", + template: "public", + }); + routerModule.router({ + name: "protected", + url: "/protected", + template: "protected", + }); + routerModule.router({ + name: "login", + url: "/login", + template: "login", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + beforeEach(() => { + $state = bootstrapSecurityTransition(); + }); + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + async function goAndAssert(name: string, expected: string) { + await $state.go(name); + await waitUntil(() => $state.current.name === expected); + expect($state.current.name).toBe(expected); + } + + it("allows transition when security policy resolves allow", async () => { + await goAndAssert("public", "public"); + + await goAndAssert("protected", "protected"); + }); + + it("denies transition when policy blocks a protected target", async () => { + $state = bootstrapSecurityTransition({ + navigation: { + rules: [ + { + state: "protected", + decision: "deny", + status: 403, + reason: "policy denied", + }, + ], + }, + }); + + await goAndAssert("public", "public"); + + let error; + + try { + await $state.go("protected"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe("public"); + }); + + it("redirects transition targets via security policy", async () => { + $state = bootstrapSecurityTransition({ + navigation: { + rules: [ + { + state: "protected", + decision: "redirect", + target: "login", + status: 302, + }, + ], + }, + }); + + await goAndAssert("public", "public"); + await goAndAssert("protected", "login"); + }); + + it("rejects redirect decisions without a target", async () => { + $state = bootstrapSecurityTransition({ + navigation: { + rules: [ + { + state: "protected", + decision: "redirect", + status: 302, + }, + ], + }, + }); + + await goAndAssert("public", "public"); + + let error; + + try { + await $state.go("protected"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe("public"); + }); + + it("rejects denied transitions with the default policy reason", async () => { + $state = bootstrapSecurityTransition({ + defaultDecision: "deny", + }); + + let error; + + try { + await $state.go("protected"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect($state.current.name).not.toBe("protected"); + }); + + it("evaluates policy when navigator is unavailable", async () => { + const descriptor = Object.getOwnPropertyDescriptor(window, "navigator"); + + Object.defineProperty(window, "navigator", { + configurable: true, + value: undefined, + }); + + try { + await goAndAssert("public", "public"); + } finally { + if (descriptor) { + Object.defineProperty(window, "navigator", descriptor); + } + } + }); +}); + +describe("transition configured security policy", () => { + let $state; + + beforeEach(() => { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + + const app = window.angular.module("configuredSecurityTransitionModule", []); + + app.config({ + $security: { + defaultDecision: "allow", + navigation: { + rules: [ + { + state: "protected", + decision: "redirect", + target: "login", + status: 302, + reason: "login required", + }, + ], + }, + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "public", + url: "/public", + template: "public", + }); + routerModule.router({ + name: "protected", + url: "/protected", + template: "protected", + }); + routerModule.router({ + name: "login", + url: "/login", + template: "login", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + "configuredSecurityTransitionModule", + ]); + + $state = $injector.get("$state"); + }); + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("redirects protected routes through configured navigation policy", async () => { + await $state.go("public"); + await waitUntil(() => $state.current.name === "public"); + + await $state.go("protected"); + await waitUntil(() => $state.current.name === "login"); + + expect($state.current.name).toBe("login"); + }); +}); + +describe("transition route-tree navigation policy", () => { + let $state; + let moduleId = 0; + + function bootstrapRoutePolicy( + securityConfig = {}, + configureStates?, + routerConfig?, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `routePolicyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + ...securityConfig, + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "admin", + url: "/admin", + abstract: true, + template: "", + policy: { + navigation: { + require: "authenticated", + redirectTo: "login", + }, + }, + }); + routerModule.router({ + name: "admin.users", + url: "/users", + template: "users", + }); + routerModule.router({ + name: "admin.roles", + url: "/roles", + template: "roles", + policy: { + navigation: { + permissions: "admin:roles", + redirectTo: "forbidden", + reason: "roles permission required", + }, + }, + }); + routerModule.router({ + name: "admin.public", + url: "/public", + template: "public", + policy: { + navigation: { + public: true, + }, + }, + }); + routerModule.router({ + name: "login", + url: "/login", + template: "login", + }); + routerModule.router({ + name: "forbidden", + url: "/forbidden", + template: "forbidden", + }); + + configureStates?.(routerModule); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + async function goAndAssert(name: string, expected: string) { + await $state.go(name); + await waitUntil(() => $state.current.name === expected); + expect($state.current.name).toBe(expected); + } + + it("inherits parent navigation policy into child routes", async () => { + $state = bootstrapRoutePolicy(); + + await goAndAssert("admin.users", "login"); + }); + + it("allows inherited protected routes when an auth branch matches", async () => { + $state = bootstrapRoutePolicy({ + branches: ["jwt"], + credentials: { + jwt: () => "token", + }, + }); + + await goAndAssert("admin.users", "admin.users"); + }); + + it("lets an explicit public child clear inherited navigation policy", async () => { + $state = bootstrapRoutePolicy(); + + await goAndAssert("admin.public", "admin.public"); + }); + + it("accumulates child permissions and uses child redirect target", async () => { + $state = bootstrapRoutePolicy({ + branches: ["jwt"], + credentials: { + jwt: () => "token", + }, + navigation: { + permissions: [], + }, + }); + + await goAndAssert("admin.roles", "forbidden"); + }); + + it("allows child permissions when configured on the navigation policy", async () => { + $state = bootstrapRoutePolicy({ + branches: ["jwt"], + credentials: { + jwt: () => "token", + }, + navigation: { + permissions: ["admin:roles"], + }, + }); + + await goAndAssert("admin.roles", "admin.roles"); + }); + + it("inherits parent policy for lazy children", async () => { + $state = bootstrapRoutePolicy({}, (routerModule) => { + routerModule.lazyState("admin.lazy", () => ({ + name: "admin.lazy", + url: "/lazy", + template: "lazy", + })); + }); + + await goAndAssert("admin.lazy", "login"); + }); + + it("builds inherited retention policy for route subtrees", async () => { + $state = bootstrapRoutePolicy({}, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + policy: { + retention: { + mode: "keep-alive", + max: 3, + pause: "background", + evict: "lru", + }, + }, + }); + routerModule.router({ + name: "workspace.editor", + url: "/editor", + template: "editor", + }); + }); + + const promise = $state.go("workspace.editor"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "keep-alive", + max: 3, + pause: "background", + evict: "lru", + states: ["workspace"], + }); + }); + + it("lets child retention policy override inherited scalar fields", async () => { + $state = bootstrapRoutePolicy({}, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + policy: { + retention: { + mode: "keep-alive", + key: "workspace", + max: 5, + pause: "background", + evict: "lru", + }, + }, + }); + routerModule.router({ + name: "workspace.editor", + url: "/editor", + template: "editor", + policy: { + retention: { + key: "editor", + max: 1, + pause: "schedulers", + evict: "oldest", + }, + }, + }); + }); + + const promise = $state.go("workspace.editor"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "keep-alive", + key: "editor", + max: 1, + pause: "schedulers", + evict: "oldest", + states: ["workspace", "workspace.editor"], + }); + }); + + it("lets a child retention policy opt out of inherited keep-alive", async () => { + $state = bootstrapRoutePolicy({}, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + policy: { + retention: { + mode: "keep-alive", + max: 5, + }, + }, + }); + routerModule.router({ + name: "workspace.editor", + url: "/editor", + template: "editor", + policy: { + retention: { + mode: "destroy", + }, + }, + }); + }); + + const promise = $state.go("workspace.editor"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "destroy", + max: 5, + states: ["workspace", "workspace.editor"], + }); + }); + + it("uses router-wide retention as the default policy", async () => { + $state = bootstrapRoutePolicy( + { + branches: ["jwt"], + credentials: { + jwt: () => "token", + }, + }, + undefined, + { + retention: { + mode: "keep-alive", + max: 2, + pause: "schedulers", + evict: "oldest", + }, + }, + ); + + const promise = $state.go("admin.users"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "keep-alive", + max: 2, + pause: "schedulers", + evict: "oldest", + states: [], + }); + }); + + it("lets route retention override router-wide defaults", async () => { + $state = bootstrapRoutePolicy( + {}, + (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + template: "workspace", + policy: { + retention: { + max: 1, + pause: "background", + }, + }, + }); + }, + { + retention: { + mode: "keep-alive", + key: "router-default", + max: 5, + pause: "schedulers", + evict: "lru", + }, + }, + ); + + const promise = $state.go("workspace"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "keep-alive", + key: "router-default", + max: 1, + pause: "background", + evict: "lru", + states: ["workspace"], + }); + }); + + it("lets a route opt out of router-wide retention", async () => { + $state = bootstrapRoutePolicy( + {}, + (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + template: "workspace", + policy: { + retention: { + mode: "destroy", + }, + }, + }); + }, + { + retention: { + mode: "keep-alive", + max: 5, + }, + }, + ); + + const promise = $state.go("workspace"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toEqual({ + mode: "destroy", + max: 5, + states: ["workspace"], + }); + }); + + it("omits effective retention policy when no route declares one", async () => { + $state = bootstrapRoutePolicy({ + branches: ["jwt"], + credentials: { + jwt: () => "token", + }, + }); + + const promise = $state.go("admin.users"); + const retention = buildEffectiveRetentionPolicy(promise.transition); + + await promise; + + expect(retention).toBeUndefined(); + }); + + it("assigns default retention fields to retained view configs", () => { + const viewConfig: any = { + _path: [ + { + paramValues: {}, + state: { + name: "workspace", + self: { + name: "workspace", + policy: { + retention: { + mode: "keep-alive", + }, + }, + }, + }, + }, + ], + _targetKey: "$default", + _retention: undefined, + }; + + applyViewRetention( + { + _routerState: { + _retention: undefined, + }, + } as any, + viewConfig, + ); + + expect(viewConfig._retention).toEqual({ + _mode: "keep-alive", + _key: "workspace?#$default", + _max: 10, + _pause: undefined, + _evict: undefined, + _state: "workspace", + }); + }); + + it("rejects retention key policies that do not return strings", () => { + const transition: any = { + _routerState: { + _injector: { + invoke() { + return 123; + }, + }, + }, + }; + const viewConfig: any = { + _path: [ + { + paramValues: { + id: "one", + }, + state: { + name: "workspace", + self: { + name: "workspace", + policy: { + retention: { + mode: "keep-alive", + key: function () { + return "ignored"; + }, + }, + }, + }, + }, + }, + ], + _targetKey: "$default", + _retention: undefined, + }; + + expect(() => applyViewRetention(transition, viewConfig)).toThrowError( + "Retention key policy must return a string.", + ); + }); +}); + +describe("transition canExit policy", () => { + let $state; + let moduleId = 0; + + function bootstrapCanExitStates(canExitPolicy) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `canExitPolicyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "edit", + url: "/edit", + template: "edit", + policy: { + transition: { + canExit: canExitPolicy, + }, + }, + }); + routerModule.router({ + name: "confirm", + url: "/confirm", + template: "confirm", + }); + routerModule.router({ + name: "home", + url: "/home", + template: "home", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function goAndAssert(name: string, expected: string) { + $state._defaultErrorHandler = function () {}; + return $state + .go(name) + .catch(() => {}) + .then(() => waitUntil(() => $state.current.name === expected)) + .then(() => expect($state.current.name).toBe(expected)); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("blocks transition when canExit returns false", async () => { + $state = bootstrapCanExitStates((context) => { + context.operation; + + return false; + }); + + await goAndAssert("edit", "edit"); + await goAndAssert("home", "edit"); + }); + + it("allows transition when canExit resolves true", async () => { + $state = bootstrapCanExitStates((context) => { + context.operation; + + return context.to.name === "home" ? true : false; + }); + + await goAndAssert("edit", "edit"); + await goAndAssert("home", "home"); + }); + + it("redirects transition before leaving state", async () => { + $state = bootstrapCanExitStates((context) => { + context.operation; + + return context.to.name === "home" ? $state.target("confirm") : undefined; + }); + + await goAndAssert("edit", "edit"); + await goAndAssert("home", "confirm"); + }); + + it("rejects invalid canExit policy results", async () => { + $state = bootstrapCanExitStates((context) => { + context.operation; + + return context.to.name === "home" ? 123 : true; + }); + + await $state.go("edit"); + + let error; + + try { + await $state.go("home"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(String(error)).toContain( + "Route canExit policy must return boolean or redirect.", + ); + expect($state.current.name).toBe("edit"); + }); +}); + +describe("transition dirty policy", () => { + let $state; + let moduleId = 0; + + function bootstrapDirtyStates(dirtyPolicy) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `dirtyPolicyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "editor", + url: "/editor", + template: "editor", + policy: { + transition: { + dirty: dirtyPolicy, + }, + }, + }); + routerModule.router({ + name: "saved", + url: "/saved", + template: "saved", + }); + routerModule.router({ + name: "home", + url: "/home", + template: "home", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function goAndAssert(name: string, expected: string) { + $state._defaultErrorHandler = function () {}; + return $state + .go(name) + .catch(() => {}) + .then(() => waitUntil(() => $state.current.name === expected)) + .then(() => expect($state.current.name).toBe(expected)); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("blocks transition when dirty policy returns false", async () => { + $state = bootstrapDirtyStates({ + when: () => true, + prompt: "Discard edits?", + }); + + const confirmSpy = spyOn(window, "confirm").and.returnValue(false); + + await goAndAssert("editor", "editor"); + await goAndAssert("saved", "editor"); + + expect(confirmSpy).toHaveBeenCalledWith("Discard edits?"); + }); + + it("allows transition when dirty policy returns false", async () => { + $state = bootstrapDirtyStates({ + when: () => false, + prompt: "Discard edits?", + }); + + spyOn(window, "confirm"); + + await goAndAssert("editor", "editor"); + await goAndAssert("saved", "saved"); + }); + + it("redirects transition from dirty state instead of blocking when configured", async () => { + $state = bootstrapDirtyStates({ + when: (context) => context.to.name === "saved", + redirectTo: "home", + }); + + await goAndAssert("editor", "editor"); + await goAndAssert("saved", "home"); + }); + + it("blocks dirty transitions without a prompt", async () => { + $state = bootstrapDirtyStates({ + when: () => true, + }); + + await $state.go("editor"); + + let error; + + try { + await $state.go("saved"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(String(error)).toContain("Route dirty policy blocked transition"); + expect($state.current.name).toBe("editor"); + }); +}); + +describe("transition policy composition with navigation policy", () => { + let $state; + let $security; + let moduleId = 0; + + function bootstrapCompositionStates(canExitPolicy) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `policyCompositionModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "admin", + url: "/admin", + abstract: true, + template: "", + policy: { + navigation: { + require: ["authenticated"], + redirectTo: "login", + }, + }, + }); + routerModule.router({ + name: "admin.dashboard", + url: "/dashboard", + template: "admin-dashboard", + }); + routerModule.router({ + name: "home", + url: "/home", + template: "home", + policy: { + transition: { + canExit: canExitPolicy, + }, + }, + }); + routerModule.router({ + name: "public", + url: "/public", + template: "public", + }); + routerModule.router({ + name: "login", + url: "/login", + template: "login", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + $security = $injector.get("$security"); + return $injector.get("$state"); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("does not bypass navigation guards when a non-security policy redirects", async () => { + $state = bootstrapCompositionStates(() => "admin.dashboard"); + + await $state.go("home"); + + const checkSpy = spyOn($security, "check").and.callThrough(); + await $state.go("public").catch(() => {}); + + const navigationCalls = checkSpy.calls.all(); + + expect( + navigationCalls.some( + (call) => call.args[0]?.to?.name === "admin.dashboard", + ), + ).toBeTrue(); + }); +}); + +describe("transition loading policy", () => { + let $state; + let $transitions; + let moduleId = 0; + + function bootstrapLoadingPolicyStates( + policy: any = "loading", + routerConfig?: any, + includeRoutePolicy = true, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `loadingPolicyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "loading", + url: "/loading", + template: "loading", + }); + + routerModule.router({ + name: "profile", + url: "/profile", + template: "profile", + ...(includeRoutePolicy + ? { + policy: { + transition: { + loading: policy, + }, + }, + } + : {}), + resolve: { + payload: () => + new Promise((resolve) => { + setTimeout(() => resolve("ok"), 5); + }), + }, + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return { + $state: $injector.get("$state"), + $transitions: $injector.get("$transitions"), + }; + } + + function bootstrapInheritedLoadingOverrideStates() { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `loadingPolicyOverrideModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "parent", + url: "/parent", + abstract: true, + template: "", + policy: { + transition: { + loading: "loading", + }, + }, + }); + + routerModule.router({ + name: "loading", + url: "/loading", + template: "loading", + }); + + routerModule.router({ + name: "parent.editor", + url: "/editor", + template: "editor", + policy: { + transition: { + loading: false, + }, + }, + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return { + $state: $injector.get("$state"), + $transitions: $injector.get("$transitions"), + }; + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("navigates through a loading state before the final target", async () => { + const result = bootstrapLoadingPolicyStates(); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + const transition = $state.go("profile"); + + await transition; + + deregister(); + + expect(transitionOrder).toContain("loading"); + expect(transitionOrder).toEqual(["loading", "profile"]); + + expect($state.current.name).toBe("profile"); + }); + + it("skips loading boundaries when policy is true", async () => { + const result = bootstrapLoadingPolicyStates(true); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("skips loading boundaries that point at the target route", async () => { + const result = bootstrapLoadingPolicyStates("profile"); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("uses callable loading policies with route context", async () => { + const result = bootstrapLoadingPolicyStates( + function (context, state, from, to) { + expect(context.operation).toBe("loading"); + expect(context.state).toBe(state); + expect(context.from).toBe(from); + expect(context.to).toBe(to); + expect(state.name).toBe("profile"); + + return "loading"; + }, + ); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["loading", "profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("uses callable loading policies returning target states", async () => { + const result = bootstrapLoadingPolicyStates(function ($state) { + return $state.target("loading"); + }); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["loading", "profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("skips callable loading policies that return no boundary", async () => { + const result = bootstrapLoadingPolicyStates(() => undefined); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("skips callable loading policies that return true", async () => { + const result = bootstrapLoadingPolicyStates(() => true); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("skips callable loading policies with invalid or same-target results", async () => { + let result = bootstrapLoadingPolicyStates(() => 123); + + $state = result.$state; + $transitions = result.$transitions; + + await $state.go("profile"); + expect($state.current.name).toBe("profile"); + + result = bootstrapLoadingPolicyStates(() => ({ params: {} })); + + $state = result.$state; + + await $state.go("profile"); + expect($state.current.name).toBe("profile"); + }); + + it("supports inherited loading policies with child overrides", async () => { + const result = bootstrapInheritedLoadingOverrideStates(); + + $state = result.$state; + $transitions = result.$transitions; + + const transition = $state.go("parent.editor"); + + await transition; + + expect($state.current.name).toBe("parent.editor"); + }); + + it("uses router-wide loading policy defaults", async () => { + const result = bootstrapLoadingPolicyStates( + undefined, + { + loading: "loading", + }, + false, + ); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["loading", "profile"]); + expect($state.current.name).toBe("profile"); + }); + + it("lets route loading policy override router-wide defaults", async () => { + const result = bootstrapLoadingPolicyStates( + false, + { + loading: "loading", + }, + true, + ); + + $state = result.$state; + $transitions = result.$transitions; + const transitionOrder: string[] = []; + const deregister = $transitions.onSuccess({}, (trans: any) => { + transitionOrder.push(trans.to().name); + }); + + await $state.go("profile"); + + deregister(); + + expect(transitionOrder).toEqual(["profile"]); + expect($state.current.name).toBe("profile"); + }); +}); + +describe("transition retry policy", () => { + let $state; + let moduleId = 0; + let resolveAttempts: number; + let lazyLoadAttempts: number; + + function bootstrapRetryResolveState( + policy, + errorBoundary?: any, + routerConfig?: any, + includeRouteRetry = true, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `retryPolicyModule${moduleId}`; + + resolveAttempts = 0; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "error", + url: "/error", + template: "error", + }); + + routerModule.router({ + name: "retryResolve", + url: "/retry-resolve", + template: "retry-resolve", + policy: { + transition: { + ...(includeRouteRetry ? { retry: policy } : {}), + ...(errorBoundary !== undefined ? { errorBoundary } : {}), + }, + }, + resolve: { + payload: () => { + resolveAttempts += 1; + + if (resolveAttempts === 1) { + return Promise.reject(new Error("temporary")); + } + + return "ok"; + }, + }, + }); + + routerModule.router({ + name: "base", + url: "/base", + template: "base", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function bootstrapRetryLazyState(policy): void { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + lazyLoadAttempts = 0; + + const moduleName = `retryPolicyLazyModule${moduleId}`; + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "parent", + url: "/parent", + template: "parent", + policy: { + transition: { + retry: policy, + }, + }, + }); + + routerModule.lazyState("parent", async () => { + lazyLoadAttempts += 1; + + if (lazyLoadAttempts === 1) { + throw new Error("temporary load failure"); + } + + return [ + { + name: "parent.lazy", + url: "/lazy", + template: "parent lazy", + }, + ]; + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + $state = $injector.get("$state"); + } + + function goAndAssert(name: string, expected: string) { + $state._defaultErrorHandler = function () {}; + return $state + .go(name) + .catch(() => {}) + .then(() => waitUntil(() => $state.current.name === expected)) + .then(() => expect($state.current.name).toBe(expected)); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("retries failed transitions when retry policy allows", async () => { + $state = bootstrapRetryResolveState(2); + + await goAndAssert("retryResolve", "retryResolve"); + + expect(resolveAttempts).toBe(2); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "retry", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + expect($state._policyDiagnostics[0].error).toBeUndefined(); + }); + + it("retries failed transitions when retry policy callback allows", async () => { + $state = bootstrapRetryResolveState(function (context, state, from, to) { + expect(context.operation).toBe("retry"); + expect(context.attempt).toBe(1); + expect(context.state).toBe(state); + expect(context.from).toBe(from); + expect(context.to).toBe(to); + expect(context.error).toEqual(jasmine.any(Error)); + + return true; + }); + + await goAndAssert("retryResolve", "retryResolve"); + + expect(resolveAttempts).toBe(2); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "retry", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + }); + + it("does not retry when retry policy blocks failures", async () => { + $state = bootstrapRetryResolveState(false); + + let error; + + await goAndAssert("base", "base"); + + try { + await $state.go("retryResolve"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(resolveAttempts).toBe(1); + expect($state.current.name).toBe("base"); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "blocked", + _from: "base", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + expect($state._policyDiagnostics[0].error).toBeUndefined(); + }); + + it("does not retry when numeric retry policy allows no attempts", async () => { + $state = bootstrapRetryResolveState(0); + + let error; + + await goAndAssert("base", "base"); + + try { + await $state.go("retryResolve"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(resolveAttempts).toBe(1); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "blocked", + _from: "base", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + }); + + it("uses an error boundary after retry policy blocks recovery", async () => { + $state = bootstrapRetryResolveState(false, "error"); + + await goAndAssert("base", "base"); + await goAndAssert("retryResolve", "error"); + + expect(resolveAttempts).toBe(1); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "blocked", + _from: "base", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + }); + + it("rejects retry policy callbacks that do not return a retry decision", async () => { + $state = bootstrapRetryResolveState(() => "retry"); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("retryResolve"); + } catch (err) { + error = err; + } + + expect(error).toEqual(jasmine.any(Error)); + expect(error.message).toBe( + "Route retry policy must return boolean or number.", + ); + }); + + it("retries lazy state loading failure and inherits retry policy from parent", async () => { + bootstrapRetryLazyState(true); + + await goAndAssert("parent.lazy", "parent.lazy"); + + expect(lazyLoadAttempts).toBe(2); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "retry", + _to: "parent.lazy", + _policyState: "parent", + _attempt: 1, + }), + ]); + expect($state._policyDiagnostics[0].error).toBeUndefined(); + }); + + it("uses router-wide retry policy defaults", async () => { + $state = bootstrapRetryResolveState( + undefined, + undefined, + { + retry: 2, + }, + false, + ); + + await goAndAssert("retryResolve", "retryResolve"); + + expect(resolveAttempts).toBe(2); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "retry", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + }); + + it("lets route retry policy override router-wide defaults", async () => { + $state = bootstrapRetryResolveState(false, undefined, { + retry: 2, + }); + + await goAndAssert("base", "base"); + + let error; + + try { + await $state.go("retryResolve"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(resolveAttempts).toBe(1); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "blocked", + _to: "retryResolve", + _policyState: "retryResolve", + _attempt: 1, + }), + ]); + }); +}); + +describe("transition fallback policy", () => { + let $state; + let moduleId = 0; + let resolveAttempts: number; + + function bootstrapFallbackResolveState( + fallbackTo: any = "fallback", + routerConfig?: any, + includeRoutePolicy = true, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `fallbackPolicyModule${moduleId}`; + + resolveAttempts = 0; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "base", + url: "/base", + template: "base", + }); + + routerModule.router({ + name: "fallback", + url: "/fallback", + template: "fallback", + }); + + routerModule.router({ + name: "fail", + url: "/fail", + template: "fail", + ...(includeRoutePolicy + ? { + policy: { + transition: { + fallbackTo, + }, + }, + } + : {}), + resolve: { + payload: () => { + resolveAttempts += 1; + + return Promise.reject(new Error("temporary")); + }, + }, + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function bootstrapInheritedFallbackState() { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `fallbackInheritedModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + policy: { + transition: { + fallbackTo: "fallback", + }, + }, + }); + + routerModule.router({ + name: "workspace.editor", + url: "/editor", + template: "workspace editor", + policy: { + transition: { + retry: false, + }, + }, + resolve: { + payload: () => Promise.reject(new Error("temporary")), + }, + }); + + routerModule.router({ + name: "fallback", + url: "/fallback", + template: "fallback", + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function bootstrapLazyFallbackState(parentPolicy?: any, routerConfig?: any) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `fallbackLazyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + ...(parentPolicy !== undefined + ? { + policy: { + transition: { + fallbackTo: parentPolicy, + }, + }, + } + : {}), + }); + + routerModule.router({ + name: "fallback", + url: "/fallback", + template: "fallback", + }); + + routerModule.lazyState("workspace", () => { + return Promise.reject(new Error("lazy fallback failure")); + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function goAndAssert(name: string, expected: string) { + $state._defaultErrorHandler = function () {}; + + return $state + .go(name) + .catch(() => {}) + .then(() => waitUntil(() => $state.current.name === expected)) + .then(() => expect($state.current.name).toBe(expected)); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("falls back to a configured state on recoverable transition failures", async () => { + $state = bootstrapFallbackResolveState(); + + await goAndAssert("base", "base"); + await goAndAssert("fail", "fallback"); + + expect(resolveAttempts).toBe(1); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _from: "base", + _to: "fail", + _policyState: "fail", + _target: "fallback", + }), + ]); + expect($state._policyDiagnostics[0].error).toBeUndefined(); + }); + + it("falls back with an object target policy", async () => { + $state = bootstrapFallbackResolveState({ + state: "fallback", + params: {}, + }); + + await goAndAssert("base", "base"); + await goAndAssert("fail", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _from: "base", + _to: "fail", + _policyState: "fail", + _target: "fallback", + }), + ]); + }); + + it("falls back with object target policy default params", async () => { + $state = bootstrapFallbackResolveState({ + state: "fallback", + }); + + await goAndAssert("base", "base"); + await goAndAssert("fail", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _from: "base", + _to: "fail", + _policyState: "fail", + _target: "fallback", + }), + ]); + }); + + it("ignores invalid fallback policy shapes", async () => { + $state = bootstrapFallbackResolveState(123); + + await goAndAssert("base", "base"); + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe("base"); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _from: "base", + _to: "fail", + _policyState: "fail", + _reason: "invalid-target", + }), + ]); + }); + + it("skips fallback when object policy points at the failed target", async () => { + $state = bootstrapFallbackResolveState({ + params: {}, + }); + + await goAndAssert("base", "base"); + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe("base"); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _from: "base", + _to: "fail", + _policyState: "fail", + _target: "fail", + _reason: "same-target", + }), + ]); + }); + + it("skips fallback when policy target is invalid", async () => { + $state = bootstrapFallbackResolveState("missing"); + + await goAndAssert("base", "base"); + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe("base"); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _from: "base", + _to: "fail", + _policyState: "fail", + _reason: "invalid-target", + }), + ]); + }); + + it("uses inherited fallback policy when child transition fails", async () => { + $state = bootstrapInheritedFallbackState(); + + await goAndAssert("workspace.editor", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retry", + _decision: "blocked", + _to: "workspace.editor", + _policyState: "workspace.editor", + _attempt: 1, + }), + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _to: "workspace.editor", + _policyState: "workspace", + _target: "fallback", + }), + ]); + }); + + it("uses router-wide fallback policy defaults", async () => { + $state = bootstrapFallbackResolveState( + undefined, + { + fallbackTo: "fallback", + }, + false, + ); + + await goAndAssert("base", "base"); + await goAndAssert("fail", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _to: "fail", + _policyState: "fail", + _target: "fallback", + }), + ]); + }); + + it("falls back when lazy route loading fails", async () => { + $state = bootstrapLazyFallbackState("fallback"); + + await goAndAssert("workspace.lazy", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _to: "workspace.lazy", + _policyState: "workspace", + _target: "fallback", + }), + ]); + }); + + it("falls back from lazy loading with an object target", async () => { + $state = bootstrapLazyFallbackState({ + state: "fallback", + params: {}, + }); + + await goAndAssert("workspace.lazy", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _to: "workspace.lazy", + _target: "fallback", + }), + ]); + }); + + it("rejects partial lazy fallback objects whose default target is missing", async () => { + $state = bootstrapLazyFallbackState({ params: {} }); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejected(); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _reason: "invalid-target", + }), + ]); + }); + + it("skips lazy recovery when fallback resolves to an existing failed target", async () => { + $state = bootstrapLazyFallbackState(); + const fallback = $state.get("fallback"); + + fallback.policy = { + transition: { + fallbackTo: { params: {} }, + }, + }; + + const recovered = await $state._recoverLazyLoadFailure( + $state.target("fallback"), + new Error("failed"), + ); + + expect(recovered).toBeUndefined(); + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _target: "fallback", + _reason: "same-target", + }), + ]); + }); + + it("uses failed lazy params when fallback object only names a state", async () => { + $state = bootstrapLazyFallbackState({ state: "fallback" }); + + await goAndAssert("workspace.lazy", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _target: "fallback", + }), + ]); + }); + + it("records invalid lazy fallback policy shapes", async () => { + $state = bootstrapLazyFallbackState(123); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejected(); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "skipped", + _reason: "invalid-target", + }), + ]); + }); + + it("uses router-wide fallback defaults when lazy route loading fails", async () => { + $state = bootstrapLazyFallbackState(undefined, { + fallbackTo: "fallback", + }); + + await goAndAssert("workspace.lazy", "fallback"); + + expect($state._policyDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "fallback", + _decision: "redirected", + _to: "workspace.lazy", + _policyState: "", + _target: "fallback", + }), + ]); + }); +}); + +describe("transition error boundary policy", () => { + let $state; + let moduleId = 0; + + function bootstrapErrorBoundaryStates( + policy?: (context: any) => unknown, + useAlias?: boolean, + routerConfig?: any, + includeRoutePolicy = true, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `errorBoundaryPolicyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "error", + url: "/error", + template: "error", + }); + + routerModule.router({ + name: "fail", + url: "/fail", + template: "fail", + ...(includeRoutePolicy + ? { + policy: useAlias + ? { + transition: { + error: policy, + }, + } + : { + transition: { + errorBoundary: policy, + }, + }, + } + : {}), + resolve: { + payload: () => Promise.reject(new Error("boundary test")), + }, + }); + + routerModule.router({ + name: "parent", + url: "/parent", + abstract: true, + template: "", + policy: { + transition: { + errorBoundary: (context) => { + if (context.error) { + return { + state: "error", + }; + } + + return "error"; + }, + }, + }, + }); + + routerModule.router({ + name: "parent.sibling", + url: "/sibling", + template: "sibling", + }); + + routerModule.router({ + name: "parent.failingChild", + url: "/failing-child", + template: "failing child", + resolve: { + payload: () => Promise.reject(new Error("child failed")), + }, + }); + + routerModule.router({ + name: "errorChildOverride", + url: "/error-child-override", + template: "error child override", + }); + + routerModule.router({ + name: "parent.failingChildOverride", + url: "/failing-child-override", + template: "failing child override", + policy: { + transition: { + errorBoundary: "errorChildOverride", + }, + }, + resolve: { + payload: () => Promise.reject(new Error("child override failed")), + }, + }); + + routerModule.router({ + name: "parent.child", + url: "/child", + template: "child", + resolve: { + payload: () => Promise.resolve("ok"), + }, + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function bootstrapLazyErrorBoundaryState( + parentPolicy?: any, + routerConfig?: any, + ) { + dealoc(document.getElementById("app")); + window.angular = new Angular(); + moduleId += 1; + + const moduleName = `errorBoundaryLazyModule${moduleId}`; + + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + }, + }); + + if (routerConfig) { + app.config({ + $router: routerConfig, + }); + } + + configureRoutes(app, (routerModule) => { + routerModule.router({ + name: "workspace", + url: "/workspace", + abstract: true, + template: "", + ...(parentPolicy !== undefined + ? { + policy: { + transition: { + errorBoundary: parentPolicy, + }, + }, + } + : {}), + }); + + routerModule.router({ + name: "error", + url: "/error", + template: "error", + }); + + routerModule.router({ + name: "base", + url: "/base", + template: "base", + }); + + routerModule.lazyState("workspace", () => { + return Promise.reject(new Error("lazy error boundary failure")); + }); + }); + + const $injector = window.angular.bootstrap(document.getElementById("app"), [ + moduleName, + ]); + + return $injector.get("$state"); + } + + function goAndAssert(name: string, expected: string) { + $state._defaultErrorHandler = function () {}; + return $state + .go(name) + .catch(() => {}) + .then(() => waitUntil(() => $state.current.name === expected)) + .then(() => expect($state.current.name).toBe(expected)); + } + + afterEach(() => { + dealoc(document.getElementById("app")); + }); + + it("redirects to declared error boundary on recoverable failure", async () => { + $state = bootstrapErrorBoundaryStates("error"); + + await goAndAssert("fail", "error"); + }); + + it("redirects to declared error boundary object on recoverable failure", async () => { + $state = bootstrapErrorBoundaryStates({ + state: "error", + params: {}, + }); + + await goAndAssert("fail", "error"); + }); + + it("redirects to declared error boundary object with default params", async () => { + $state = bootstrapErrorBoundaryStates({ + state: "error", + }); + + await goAndAssert("fail", "error"); + }); + + it("redirects to a direct target state error boundary", async () => { + $state = bootstrapErrorBoundaryStates("error"); + $state.get("fail").policy.transition.errorBoundary = $state.target("error"); + + await goAndAssert("fail", "error"); + }); + + it("ignores invalid declared error boundary targets", async () => { + $state = bootstrapErrorBoundaryStates("missing"); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe(""); + }); + + it("ignores same-target declared error boundaries", async () => { + $state = bootstrapErrorBoundaryStates({ + params: {}, + }); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe(""); + }); + + it("ignores non-callable error boundary policy values", async () => { + $state = bootstrapErrorBoundaryStates(null); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe(""); + }); + + it("supports alias `error` for recoverable failure boundaries", async () => { + $state = bootstrapErrorBoundaryStates("error", true); + + await goAndAssert("fail", "error"); + }); + + it("inherits parent error boundary when child has no policy", async () => { + $state = bootstrapErrorBoundaryStates((context) => { + context.error; + + return undefined; + }); + + await goAndAssert("parent.failingChild", "error"); + }); + + it("lets a child error boundary override an inherited parent boundary", async () => { + $state = bootstrapErrorBoundaryStates((context) => { + context.error; + + return undefined; + }); + + await goAndAssert("parent.failingChildOverride", "errorChildOverride"); + }); + + it("uses injectable policy to redirect with context error", async () => { + $state = bootstrapErrorBoundaryStates((context) => { + expect(context.operation).toBe("error"); + expect(context.error).toEqual(jasmine.any(Error)); + + return "error"; + }); + + await goAndAssert("fail", "error"); + }); + + it("ignores injectable same-target error boundary objects", async () => { + $state = bootstrapErrorBoundaryStates(() => ({ + params: {}, + })); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe(""); + }); + + it("uses injectable policy to redirect with a target state", async () => { + $state = bootstrapErrorBoundaryStates(function ($state) { + return $state.target("error"); + }); + + await goAndAssert("fail", "error"); + }); + + it("rejects injectable error boundary policy results with invalid shape", async () => { + $state = bootstrapErrorBoundaryStates(() => 123); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toEqual(jasmine.any(Error)); + expect(error.message).toBe( + "Route error boundary policy must return TargetState, redirect target, or undefined.", + ); + }); + + it("uses router-wide error boundary defaults", async () => { + $state = bootstrapErrorBoundaryStates( + undefined, + false, + { + errorBoundary: "error", + }, + false, + ); + + await goAndAssert("fail", "error"); + }); + + it("lets route error boundary override router-wide defaults", async () => { + $state = bootstrapErrorBoundaryStates("missing", false, { + errorBoundary: "error", + }); + $state._defaultErrorHandler = function () {}; + + let error; + + try { + await $state.go("fail"); + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect($state.current.name).toBe(""); + }); + + it("uses inherited error boundary when lazy route loading fails", async () => { + $state = bootstrapLazyErrorBoundaryState("error"); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("uses callable error boundary when lazy route loading fails", async () => { + $state = bootstrapLazyErrorBoundaryState((context) => { + expect(context.operation).toBe("error"); + expect(context.transition).toBeUndefined(); + expect(context.error).toEqual(jasmine.any(Error)); + + return "error"; + }); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("evaluates lazy error boundaries from the current route", async () => { + $state = bootstrapLazyErrorBoundaryState((context) => { + expect(context.from.name).toBe("base"); + expect(context.to.name).toBe("base"); + + return { + state: "error", + params: {}, + }; + }); + + await goAndAssert("base", "base"); + await goAndAssert("workspace.lazy", "error"); + }); + + it("uses a direct target error boundary when lazy loading fails", async () => { + $state = bootstrapLazyErrorBoundaryState("error"); + $state.get("workspace").policy.transition.errorBoundary = + $state.target("error"); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("uses an object error boundary when lazy loading fails", async () => { + $state = bootstrapLazyErrorBoundaryState({ + state: "error", + params: {}, + }); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("uses failed lazy params for partial object error boundaries", async () => { + $state = bootstrapLazyErrorBoundaryState({ state: "error" }); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("ignores same-target lazy object error boundaries", async () => { + $state = bootstrapLazyErrorBoundaryState({ params: {} }); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejected(); + + expect($state.current.name).toBe(""); + }); + + it("uses callable target and object results for lazy error boundaries", async () => { + $state = bootstrapLazyErrorBoundaryState(function ($state) { + return $state.target("error"); + }); + + await goAndAssert("workspace.lazy", "error"); + + $state = bootstrapLazyErrorBoundaryState(() => ({ state: "error" })); + + await goAndAssert("workspace.lazy", "error"); + }); + + it("allows callable lazy error boundaries to decline recovery", async () => { + $state = bootstrapLazyErrorBoundaryState(() => undefined); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejected(); + + expect($state.current.name).toBe(""); + }); + + it("rejects invalid callable lazy error boundary results", async () => { + $state = bootstrapLazyErrorBoundaryState(() => 123); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejectedWithError( + "Route error boundary policy must return TargetState, redirect target, or undefined.", + ); + }); + + it("rejects callable lazy error boundary objects without a target", async () => { + $state = bootstrapLazyErrorBoundaryState(() => ({})); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejectedWithError( + "Route error boundary policy must return TargetState, redirect target, or undefined.", + ); + }); + + it("uses failed target names for callable lazy boundary params", async () => { + $state = bootstrapLazyErrorBoundaryState(); + const errorState = $state.get("error"); + + errorState.policy = { + transition: { + errorBoundary: () => ({ params: {} }), + }, + }; + + const recovered = await $state._recoverLazyLoadFailure( + $state.target("error"), + new Error("failed"), + ); + + expect(recovered).toBeUndefined(); + }); + + it("ignores non-callable lazy error boundary values", async () => { + $state = bootstrapLazyErrorBoundaryState(null); + $state._defaultErrorHandler = function () {}; + + await expectAsync($state.go("workspace.lazy")).toBeRejected(); + + expect($state.current.name).toBe(""); + }); + + it("uses router-wide error boundary defaults when lazy route loading fails", async () => { + $state = bootstrapLazyErrorBoundaryState(undefined, { + errorBoundary: "error", + }); + + await goAndAssert("workspace.lazy", "error"); + }); +}); diff --git a/src/router/transition/security-policy.test.ts b/src/router/transition/security-policy.test.ts new file mode 100644 index 000000000..04eb7c27f --- /dev/null +++ b/src/router/transition/security-policy.test.ts @@ -0,0 +1,8 @@ +import { test } from "@playwright/test"; +import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; + +const TEST_URL = "src/router/transition/security-policy.html"; + +test("security policy unit tests contain no errors", async ({ page }) => { + await expectNoJasmineFailures(page, TEST_URL); +}); diff --git a/src/router/transition/transition-hook.html b/src/router/transition/transition-hook.html index 009ff8d71..0eaa5cefb 100644 --- a/src/router/transition/transition-hook.html +++ b/src/router/transition/transition-hook.html @@ -16,5 +16,7 @@ src="/src/router/transition/transition-hook.spec.ts" > - + +
+ diff --git a/src/router/transition/transition-hook.spec.ts b/src/router/transition/transition-hook.spec.ts index 632247851..de7010328 100644 --- a/src/router/transition/transition-hook.spec.ts +++ b/src/router/transition/transition-hook.spec.ts @@ -3,6 +3,15 @@ import { TransitionHook, TransitionHookPhase } from "./transition-hook.ts"; import { Rejection, RejectType } from "./reject-factory.ts"; import { TransitionEventType } from "./transition-event-type.ts"; +import { + afterPaintTask, + applyRouterFocus, + applyRouterScroll, + resolveFocusTarget, + resolveScrollTarget, + scrollToHash, +} from "./transition-hooks.ts"; +import "./security-policy.spec.ts"; function createHook({ callback = () => undefined, @@ -45,6 +54,191 @@ function createHook({ } describe("TransitionHook", () => { + let originalHash: string; + let originalScrollTo: typeof window.scrollTo; + + beforeEach(() => { + originalHash = window.location.hash; + originalScrollTo = window.scrollTo; + }); + + afterEach(() => { + window.location.hash = originalHash; + window.scrollTo = originalScrollTo; + document.querySelectorAll("[data-router-helper-test]").forEach((node) => { + node.remove(); + }); + }); + + it("falls back to a timer when requestAnimationFrame is unavailable", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + window, + "requestAnimationFrame", + ); + + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: undefined, + }); + + try { + await afterPaintTask(); + expect(true).toBeTrue(); + } finally { + if (descriptor) { + Object.defineProperty(window, "requestAnimationFrame", descriptor); + } + } + }); + + it("resolves scroll and focus targets through the document", () => { + const target = document.createElement("button"); + target.id = "router-helper-target"; + target.setAttribute("data-router-helper-test", ""); + document.body.appendChild(target); + + expect(resolveScrollTarget("#router-helper-target")).toBe(target); + expect(resolveFocusTarget("#router-helper-target")).toBe(target); + expect(resolveFocusTarget({ selector: "#router-helper-target" })).toBe( + target, + ); + }); + + it("handles missing DOM globals in router UX helpers", () => { + expect(resolveScrollTarget("#missing", null)).toBeNull(); + expect(scrollToHash(null, document)).toBeFalse(); + expect(scrollToHash(window, null)).toBeFalse(); + expect(resolveFocusTarget("#missing", null)).toBeNull(); + }); + + it("scrolls to hash targets when present", () => { + const target = document.createElement("section"); + target.id = "router-hash-target"; + target.setAttribute("data-router-helper-test", ""); + target.scrollIntoView = jasmine.createSpy("scrollIntoView"); + document.body.appendChild(target); + + window.location.hash = "#"; + expect(scrollToHash()).toBeFalse(); + + window.location.hash = "#missing-router-hash-target"; + expect(scrollToHash()).toBeFalse(); + + window.location.hash = "#router-hash-target"; + expect(scrollToHash()).toBeTrue(); + expect(target.scrollIntoView).toHaveBeenCalled(); + }); + + it("applies router scroll policies", () => { + const target = document.createElement("section"); + target.id = "router-scroll-target"; + target.setAttribute("data-router-helper-test", ""); + target.scrollIntoView = jasmine.createSpy("scrollIntoView"); + document.body.appendChild(target); + + const scrollTo = jasmine.createSpy("scrollTo"); + window.scrollTo = scrollTo as typeof window.scrollTo; + + applyRouterScroll({ _scroll: undefined } as any); + applyRouterScroll({ _scroll: "preserve" } as any); + expect(scrollTo).not.toHaveBeenCalled(); + + applyRouterScroll({ + _scroll: { + selector: "#router-scroll-target", + behavior: "smooth", + }, + } as any); + expect(target.scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + }); + + applyRouterScroll({ + _scroll: { + left: 4, + top: 8, + behavior: "auto", + }, + } as any); + expect(scrollTo).toHaveBeenCalledWith({ + behavior: "auto", + left: 4, + top: 8, + }); + + applyRouterScroll({ + _scroll: { + behavior: "smooth", + }, + } as any); + expect(scrollTo).toHaveBeenCalledWith({ + behavior: "smooth", + left: 0, + top: 0, + }); + + applyRouterScroll({ _scroll: { behavior: "auto" } } as any, null); + expect(scrollTo).toHaveBeenCalledTimes(2); + + applyRouterScroll({ _scroll: true } as any); + expect(scrollTo).toHaveBeenCalledWith({ left: 0, top: 0 }); + }); + + it("applies hash router scroll policies", () => { + const target = document.createElement("section"); + target.id = "router-hash-scroll-target"; + target.setAttribute("data-router-helper-test", ""); + target.scrollIntoView = jasmine.createSpy("scrollIntoView"); + document.body.appendChild(target); + + const scrollTo = jasmine.createSpy("scrollTo"); + window.scrollTo = scrollTo as typeof window.scrollTo; + + window.location.hash = "#router-hash-scroll-target"; + applyRouterScroll({ _scroll: "hash" } as any); + expect(target.scrollIntoView).toHaveBeenCalled(); + expect(scrollTo).not.toHaveBeenCalled(); + + window.location.hash = "#missing-router-hash-scroll-target"; + applyRouterScroll({ _scroll: "hash" } as any); + expect(scrollTo).toHaveBeenCalledWith({ left: 0, top: 0 }); + }); + + it("applies router focus policies", () => { + const explicit = document.createElement("button"); + explicit.id = "router-focus-explicit"; + explicit.setAttribute("data-router-helper-test", ""); + explicit.focus = jasmine.createSpy("explicitFocus"); + document.body.appendChild(explicit); + + const fallback = document.createElement("main"); + fallback.id = "router-focus-fallback"; + fallback.setAttribute("data-router-focus", ""); + fallback.setAttribute("data-router-helper-test", ""); + fallback.focus = jasmine.createSpy("fallbackFocus"); + document.body.appendChild(fallback); + + applyRouterFocus({ _focus: false } as any); + expect(explicit.focus).not.toHaveBeenCalled(); + + applyRouterFocus({ _focus: "#router-focus-explicit" } as any); + expect(explicit.focus).toHaveBeenCalledWith({ preventScroll: true }); + + applyRouterFocus({ + _focus: { + selector: "#router-focus-explicit", + preventScroll: false, + }, + } as any); + expect(explicit.focus).toHaveBeenCalledWith({ preventScroll: false }); + + applyRouterFocus({ _focus: true } as any); + expect(fallback.focus).toHaveBeenCalledWith({ preventScroll: true }); + + applyRouterFocus({ _focus: "#missing-router-focus-target" } as any); + expect(explicit.focus).toHaveBeenCalledTimes(2); + }); + it("chains hooks without an initial promise", async () => { const order = []; diff --git a/src/router/transition/transition-hooks.ts b/src/router/transition/transition-hooks.ts index 643fa0645..f108f6b13 100644 --- a/src/router/transition/transition-hooks.ts +++ b/src/router/transition/transition-hooks.ts @@ -10,21 +10,34 @@ import { import { Resolvable } from "../resolve/resolvable.ts"; import { ResolveContext } from "../resolve/resolve-context.ts"; import { TargetState } from "../state/target-state.ts"; +import { + createRetentionPolicyInvocationLocals, + createTransitionPolicyInvocationLocals, +} from "../invocation-context.ts"; import { loadViewConfig, type ViewConfig, + type ViewRetentionAssignment, type ViewService, } from "../view/view.ts"; import { Rejection } from "./reject-factory.ts"; import { Transition } from "./transition.ts"; +import type { NavigationPolicyContext } from "../../services/security/security.ts"; import type { BuiltStateDeclaration, + InternalStateDeclaration, RedirectToResult, + StateNavigationPolicyDeclaration, + StateRetentionPolicyDeclaration, + StateRetentionPolicyContext, StateDeclaration, + StateTransitionPolicyContext, + StateTransitionLoadingPolicy, + StateTransitionLoadingPolicyContext, } from "../state/interface.ts"; import type { StateObject } from "../state/state-object.ts"; import type { PathNode } from "../path/path-node.ts"; -import type { StateProvider } from "../state/state-service.ts"; +import type { StateRuntime } from "../state/state-service.ts"; import type { DeregisterFn, HookResult } from "./interface.ts"; import type { TransitionService } from "./transition-service.ts"; @@ -38,6 +51,23 @@ async function afterViewCommitTask(): Promise { }); } +/** @internal */ +export async function afterPaintTask(): Promise { + if (typeof requestAnimationFrame === "undefined") { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} + type ViewTransitionDocument = Document & { startViewTransition: (updateCallback: () => void) => { updateCallbackDone: Promise; @@ -98,6 +128,9 @@ export function registerCoreTransitionHooks( registerAddCoreResolvables(transitionService); registerIgnoredTransitionHook(transitionService); registerInvalidTransitionHook(transitionService); + registerSecurityNavigationPolicyHook(transitionService); + registerTransitionLoadingPolicyHook(transitionService); + registerTransitionPolicyHook(transitionService); registerOnExitHook(transitionService); registerOnRetainHook(transitionService); registerOnEnterHook(transitionService); @@ -115,6 +148,7 @@ export function registerRuntimeTransitionHooks( registerUpdateUrl(transitionService); registerRedirectToHook(transitionService); registerActivateViews(transitionService); + registerRouterUxHook(transitionService); } function registerAddCoreResolvables( @@ -133,12 +167,6 @@ function registerAddCoreResolvables( Resolvable.fromData("$transition$", trans), "", ); - addTransitionResolvable( - trans, - Resolvable.fromData("$stateParams", trans.params()), - "", - ); - const entering = trans.entering(); entering.forEach((state) => { @@ -259,6 +287,12 @@ function invalidTransitionHook(trans: Transition): void { } } +function internalState( + state: StateDeclaration, +): BuiltStateDeclaration | undefined { + return (state as Partial)._state?.(); +} + function registerInvalidTransitionHook( transitionService: TransitionService, ): DeregisterFn { @@ -271,21 +305,21 @@ function onExitHook( transition: Transition, state: StateDeclaration, ): HookResult { - return state._state?.().onExit?.(transition, state); + return internalState(state)?.onExit?.(transition, state); } function onRetainHook( transition: Transition, state: StateDeclaration, ): HookResult { - return state._state?.().onRetain?.(transition, state); + return internalState(state)?.onRetain?.(transition, state); } function onEnterHook( transition: Transition, state: StateDeclaration, ): HookResult { - return state._state?.().onEnter?.(transition, state); + return internalState(state)?.onEnter?.(transition, state); } function registerOnExitHook( @@ -326,7 +360,7 @@ function hasRedirectTo(state?: StateObject): boolean { } function handleRedirectToResult( - stateService: StateProvider, + stateService: StateRuntime, trans: Transition, result: RedirectToResult, ): TargetState | undefined { @@ -384,6 +418,585 @@ function registerRedirectToHook( const RESOLVE_HOOK_PRIORITY = 1000; +const DEFAULT_RETENTION_MAX = 10; + +interface EffectiveNavigationPolicy { + require: string[]; + permissions: string[]; + redirectTo?: string; + reason?: string; + public?: boolean; + states: string[]; +} + +interface EffectiveRetentionPolicy { + mode: NonNullable; + key?: StateRetentionPolicyDeclaration["key"]; + max?: number; + pause?: StateRetentionPolicyDeclaration["pause"]; + evict?: StateRetentionPolicyDeclaration["evict"]; + states: string[]; +} + +interface EffectiveTransitionLoadingPolicy { + policy: false | string | StateTransitionLoadingPolicy; + state: StateDeclaration; + states: string[]; +} + +function appendUnique(target: string[], source: string | string[] | undefined) { + if (source === undefined) return; + + const items = Array.isArray(source) ? source : [source]; + + items.forEach((item) => { + if (!target.includes(item)) { + target.push(item); + } + }); +} + +function applyNavigationPolicy( + effective: EffectiveNavigationPolicy, + policy: StateNavigationPolicyDeclaration, + stateName: string, +): void { + effective.states.push(stateName); + + if (policy.public) { + effective.require = []; + effective.permissions = []; + effective.redirectTo = undefined; + effective.reason = policy.reason; + effective.public = true; + + return; + } + + effective.public = false; + appendUnique(effective.require, policy.require); + appendUnique(effective.permissions, policy.permissions); + + if (policy.redirectTo !== undefined) { + effective.redirectTo = policy.redirectTo; + } + + if (policy.reason !== undefined) { + effective.reason = policy.reason; + } +} + +function applyRetentionPolicy( + effective: EffectiveRetentionPolicy, + policy: StateRetentionPolicyDeclaration, + stateName?: string, +): void { + if (stateName !== undefined) { + effective.states.push(stateName); + } + + if (policy.mode !== undefined) { + effective.mode = policy.mode; + } + + if (policy.key !== undefined) { + effective.key = policy.key; + } + + if (policy.max !== undefined) { + effective.max = policy.max; + } + + if (policy.pause !== undefined) { + effective.pause = policy.pause; + } + + if (policy.evict !== undefined) { + effective.evict = policy.evict; + } +} + +function applyLoadingPolicy( + effective: { policy?: false | string | StateTransitionLoadingPolicy }, + policy: boolean | string | StateTransitionLoadingPolicy, +): void { + if (isString(policy) || isFunction(policy)) { + effective.policy = policy; + + return; + } + + if (!policy) { + effective.policy = false; + + return; + } +} + +/** @internal */ +export function buildEffectiveRetentionPolicy( + transition: Transition, +): EffectiveRetentionPolicy | undefined { + return buildEffectiveRetentionPolicyFromPath( + transition._treeChanges.to, + transition._routerState._retention, + ); +} + +function buildEffectiveRetentionPolicyFromPath( + path: PathNode[], + routerPolicy?: StateRetentionPolicyDeclaration, +): EffectiveRetentionPolicy | undefined { + const effective: EffectiveRetentionPolicy = { + mode: "destroy", + states: [], + }; + + let hasPolicy = false; + + if (routerPolicy) { + applyRetentionPolicy(effective, routerPolicy); + hasPolicy = true; + } + + path.forEach((node) => { + const policy = node.state.self.policy?.retention; + + if (!policy) return; + + applyRetentionPolicy(effective, policy, node.state.name); + hasPolicy = true; + }); + + return hasPolicy ? effective : undefined; +} + +function buildEffectiveLoadingPolicy( + transition: Transition, +): EffectiveTransitionLoadingPolicy | undefined { + const effective: { + policy?: false | string | StateTransitionLoadingPolicy; + state?: StateDeclaration; + states: string[]; + } = { + states: [], + }; + + if (transition._routerState._loading !== undefined) { + applyLoadingPolicy(effective, transition._routerState._loading); + effective.state = transition.to(); + } + + transition._treeChanges.to.forEach((node) => { + const policy = node.state.self.policy?.transition?.loading; + + if (policy === undefined) return; + + applyLoadingPolicy(effective, policy); + effective.state = node.state.self; + effective.states.push(node.state.name); + }); + + return effective.state && effective.policy !== undefined + ? ({ + policy: effective.policy, + state: effective.state, + states: effective.states, + } as EffectiveTransitionLoadingPolicy) + : undefined; +} + +function pathParams(path: PathNode[]): Record { + const params: Record = {}; + + path.forEach((node) => { + keys(node.paramValues).forEach((key) => { + params[key] = node.paramValues[key]; + }); + }); + + return params; +} + +function stableParamsKey(path: PathNode[]): string { + const params = pathParams(path); + + return keys(params) + .sort() + .map((key) => `${key}:${String(params[key])}`) + .join("|"); +} + +function retentionKey( + transition: Transition, + viewConfig: ViewConfig, + policy: EffectiveRetentionPolicy, +): string { + const stateName = viewConfig._path[viewConfig._path.length - 1].state.name; + + let routeKey: string | undefined; + + if (isString(policy.key)) { + routeKey = policy.key; + } else if (policy.key) { + const targetState = viewConfig._path[viewConfig._path.length - 1].state; + + const context: StateRetentionPolicyContext = { + transition, + state: targetState.self, + params: pathParams(viewConfig._path), + }; + + const result = transition._routerState._injector?.invoke( + policy.key, + undefined, + createRetentionPolicyInvocationLocals(context), + "retention key policy", + ); + + if (!isString(result)) { + throw new Error("Retention key policy must return a string."); + } + + routeKey = result; + } + + routeKey ??= `${stateName}?${stableParamsKey(viewConfig._path)}`; + + return `${routeKey}#${viewConfig._targetKey}`; +} + +function retentionEviction( + policy: EffectiveRetentionPolicy, +): ViewRetentionAssignment["_evict"] { + return policy.evict; +} + +/** @internal */ +export function applyViewRetention( + transition: Transition, + viewConfig: ViewConfig, +): void { + const policy = buildEffectiveRetentionPolicyFromPath( + viewConfig._path, + transition._routerState._retention, + ); + + if (!policy) { + viewConfig._retention = undefined; + + return; + } + + viewConfig._retention = { + _mode: policy.mode, + _key: retentionKey(transition, viewConfig, policy), + _max: policy.max ?? DEFAULT_RETENTION_MAX, + _pause: policy.pause, + _evict: retentionEviction(policy), + _state: viewConfig._path[viewConfig._path.length - 1].state.name, + }; +} + +function buildEffectiveNavigationPolicy( + transition: Transition, +): EffectiveNavigationPolicy | undefined { + const effective: EffectiveNavigationPolicy = { + require: [], + permissions: [], + states: [], + }; + + transition._treeChanges.to.forEach((node) => { + const policy = node.state.self.policy?.navigation; + + if (!policy) return; + + applyNavigationPolicy(effective, policy, node.state.name); + }); + + return effective.states.length ? effective : undefined; +} + +/** + * Evaluates the navigation policy for each transition before controller/view hooks. + */ +async function securityNavigationHook( + this: TransitionService, + transition: Transition, +): Promise { + const from = transition.from(); + + const to = transition.to(); + + const context: NavigationPolicyContext = { + operation: "navigation", + from: { + name: from.name, + url: from.url, + }, + to: { + name: to.name, + url: to.url, + params: transition.params("to") as Record, + }, + transition: { + id: String(transition.$id), + }, + routePolicy: buildEffectiveNavigationPolicy(transition), + userAgent: + typeof navigator === "undefined" ? undefined : navigator.userAgent, + }; + + const decision = await this._security.check(context); + + if (decision.type === "allow") { + return undefined; + } + + if (decision.type === "redirect") { + if (!decision.target) { + return Promise.reject( + Rejection.errored("Security policy redirect missing target"), + ); + } + + return this._stateService.target( + decision.target, + transition.params(), + transition._options, + ); + } + + return Promise.reject( + Rejection.errored({ + reason: decision.reason ?? "Security policy denied navigation", + status: decision.status, + target: decision.target, + detail: decision, + }), + ); +} + +function registerSecurityNavigationPolicyHook( + transitionService: TransitionService, +): DeregisterFn { + return transitionService.onBefore({}, securityNavigationHook, { + bind: transitionService, + priority: 200, + }); +} + +async function transitionLoadingPolicyHook( + this: TransitionService, + transition: Transition, +): Promise { + if ( + transition._options._loadingFor || + transition._options._skipLoadingPolicy + ) { + return undefined; + } + + const effectiveLoadingPolicy = buildEffectiveLoadingPolicy(transition); + + if (!effectiveLoadingPolicy || effectiveLoadingPolicy.policy === false) { + return undefined; + } + + if ( + isString(effectiveLoadingPolicy.policy) || + isInstanceOf(effectiveLoadingPolicy.policy, TargetState) + ) { + const redirectTarget = handleRedirectToResult( + this._stateService, + transition, + effectiveLoadingPolicy.policy, + ); + + if (!redirectTarget || redirectTarget.name() === transition.to().name) { + return undefined; + } + + const options = assign({}, transition._options, { + _loadingFor: { + identifier: transition.to(), + params: transition.params(), + options: transition._options, + }, + _skipLoadingPolicy: true, + }); + + return this._stateService.target( + redirectTarget.name(), + redirectTarget.params(), + options, + ); + } + + const context: StateTransitionLoadingPolicyContext = { + operation: "loading", + transition, + from: transition.from(), + to: transition.to(), + state: effectiveLoadingPolicy.state, + }; + + const target = transition._routerState._injector?.invoke( + effectiveLoadingPolicy.policy, + undefined, + createTransitionPolicyInvocationLocals(context), + "route loading policy", + ); + + const redirectTarget = await Promise.resolve(target); + + if (!redirectTarget) { + return undefined; + } + + if (redirectTarget === true) return undefined; + + const loadingTarget = handleRedirectToResult( + this._stateService, + transition, + redirectTarget, + ); + + if (!loadingTarget || loadingTarget.name() === transition.to().name) { + return undefined; + } + + const options = assign({}, transition._options, { + _loadingFor: { + identifier: transition.to(), + params: transition.params(), + options: transition._options, + }, + _skipLoadingPolicy: true, + }); + + return this._stateService.target( + loadingTarget.name(), + loadingTarget.params(), + options, + ); +} + +function registerTransitionLoadingPolicyHook( + transitionService: TransitionService, +): DeregisterFn { + return transitionService.onBefore({}, transitionLoadingPolicyHook, { + bind: transitionService, + priority: 150, + }); +} + +/** + * Evaluates state transition policies for states being exited. + */ +async function transitionPolicyHook( + this: TransitionService, + transition: Transition, +): Promise { + const from = transition.from(); + const to = transition.to(); + + for (const state of transition.exiting()) { + const policy = state.policy?.transition; + + if (!policy) continue; + + if (policy.canExit) { + const context: StateTransitionPolicyContext = { + operation: "canExit", + transition, + from, + to, + state, + }; + + const result = await Promise.resolve( + this._routerState._injector?.invoke( + policy.canExit, + undefined, + createTransitionPolicyInvocationLocals(context), + "route canExit policy", + ), + ); + + if (result === true || result === undefined) { + // continue + } else if (result === false) { + throw Rejection.aborted("Route canExit policy blocked transition"); + } else { + const redirectTarget = handleRedirectToResult( + this._stateService, + transition, + result, + ); + + if (redirectTarget) { + return redirectTarget; + } + + throw new Error( + "Route canExit policy must return boolean or redirect.", + ); + } + } + + if (!policy.dirty) continue; + + const dirtyPolicy = policy.dirty; + const context: StateTransitionPolicyContext = { + operation: "dirty", + transition, + from, + to, + state, + }; + const shouldPrompt = await Promise.resolve( + this._routerState._injector?.invoke( + dirtyPolicy.when, + undefined, + createTransitionPolicyInvocationLocals(context), + "route dirty policy", + ), + ); + + if (!shouldPrompt) continue; + + if (dirtyPolicy.redirectTo) { + return this._stateService.target( + dirtyPolicy.redirectTo, + transition.params(), + transition._options, + ); + } + + const prompt = dirtyPolicy.prompt; + if (!prompt) { + throw Rejection.aborted("Route dirty policy blocked transition"); + } + + if (!window.confirm(prompt)) { + throw Rejection.aborted("Route dirty policy blocked transition"); + } + } + + return undefined; +} + +function registerTransitionPolicyHook( + transitionService: TransitionService, +): DeregisterFn { + return transitionService.onBefore({}, transitionPolicyHook, { + bind: transitionService, + priority: 100, + }); +} + async function eagerResolvePath(trans: Transition): Promise { return new ResolveContext(trans._treeChanges.to, trans._routerState._injector) .resolvePath(true, trans) @@ -402,7 +1015,7 @@ async function lazyResolveState( trans: Transition, state: StateDeclaration, ): Promise { - const stateObject = state._state?.(); + const stateObject = internalState(state); if (!stateObject) { throw new Error(`State '${state.name}' is not built`); @@ -465,9 +1078,18 @@ function registerLoadEnteringViews( function updateViewConfigs( viewService: ViewService, + transition: Transition, enteringViews: ViewConfig[], exitingViews: ViewConfig[], ): void { + exitingViews.forEach((view) => { + applyViewRetention(transition, view); + }); + + enteringViews.forEach((view) => { + applyViewRetention(transition, view); + }); + exitingViews.forEach((view) => { viewService._deactivateViewConfig(view); }); @@ -494,10 +1116,16 @@ async function activateViewsHook( } const updateViews = (): void => { - updateViewConfigs(viewService, enteringViews, exitingViews); + updateViewConfigs(viewService, transition, enteringViews, exitingViews); }; - if (!hasConnectedNgView(viewService)) { + if ( + transition._options._loadingFor || + transition._options._skipLoadingPolicy || + transition._options.redirectedFrom || + transition._routerState._viewTransitions === false || + !hasConnectedNgView(viewService) + ) { updateViews(); return Promise.resolve(); @@ -514,6 +1142,159 @@ function registerActivateViews( }); } +/** @internal */ +export function resolveScrollTarget( + selector: string, + browserDocument: Document | null | undefined = ( + globalThis as { + document?: Document; + } + ).document, +): Element | null { + return browserDocument?.querySelector(selector) ?? null; +} + +/** @internal */ +export function scrollToHash( + browserWindow: Window | null | undefined = ( + globalThis as { + window?: Window; + } + ).window, + browserDocument: Document | null | undefined = ( + globalThis as { + document?: Document; + } + ).document, +): boolean { + if (!browserDocument || !browserWindow) { + return false; + } + + const hash = browserWindow.location.hash; + + if (!hash || hash === "#") return false; + + const id = decodeURIComponent(hash.slice(1)); + + const target = browserDocument.getElementById(id); + + if (!target) return false; + + target.scrollIntoView(); + + return true; +} + +/** @internal */ +export function applyRouterScroll( + routerState: TransitionService["_routerState"], + browserWindow: Window | null | undefined = ( + globalThis as { + window?: Window; + } + ).window, + browserDocument: Document | null | undefined = ( + globalThis as { + document?: Document; + } + ).document, +): void { + const scroll = routerState._scroll; + + if (!scroll || scroll === "preserve") return; + + if (scroll === "hash") { + if (scrollToHash(browserWindow, browserDocument)) return; + } + + if (isObject(scroll)) { + if (scroll.selector) { + resolveScrollTarget(scroll.selector, browserDocument)?.scrollIntoView({ + behavior: scroll.behavior, + }); + + return; + } + + if (browserWindow) { + browserWindow.scrollTo({ + behavior: scroll.behavior, + left: scroll.left ?? 0, + top: scroll.top ?? 0, + }); + } + + return; + } + + if (browserWindow) { + browserWindow.scrollTo({ left: 0, top: 0 }); + } +} + +/** @internal */ +export function resolveFocusTarget( + focus: Exclude, + browserDocument: Document | null | undefined = ( + globalThis as { + document?: Document; + } + ).document, +): HTMLElement | null { + if (!browserDocument) return null; + + if (isString(focus)) { + return browserDocument.querySelector(focus); + } + + if (isObject(focus) && focus.selector) { + return browserDocument.querySelector(focus.selector); + } + + return browserDocument.querySelector( + "[autofocus], [data-router-focus], main, h1", + ); +} + +/** @internal */ +export function applyRouterFocus( + routerState: TransitionService["_routerState"], + browserDocument: Document | null | undefined = ( + globalThis as { + document?: Document; + } + ).document, +): void { + const focus = routerState._focus; + + if (!focus) return; + + const target = resolveFocusTarget(focus, browserDocument); + + if (!target) return; + + const preventScroll = isObject(focus) ? focus.preventScroll : true; + + target.focus({ preventScroll }); +} + +async function routerUxHook(this: TransitionService): Promise { + await afterViewCommitTask(); + await afterPaintTask(); + applyRouterScroll(this._routerState); + applyRouterFocus(this._routerState); +} + +function registerRouterUxHook( + transitionService: TransitionService, +): DeregisterFn { + return transitionService.onSuccess({}, routerUxHook, { + bind: transitionService, + priority: -100, + }); +} + function hasConnectedNgView(viewService: ViewService): boolean { const ngViews = viewService._ngViews; diff --git a/src/router/transition/transition-options-types.spec.ts b/src/router/transition/transition-options-types.spec.ts new file mode 100644 index 000000000..5db712830 --- /dev/null +++ b/src/router/transition/transition-options-types.spec.ts @@ -0,0 +1,47 @@ +import type { + HookResult, + InternalTransitionOptions, + TransitionOptions, +} from "./interface.ts"; + +const publicOptions: TransitionOptions = { + inherit: true, + location: "replace", + reload: false, +}; + +const internalOptions: InternalTransitionOptions = { + ...publicOptions, + source: "ng-state", + current: () => null, +}; + +void internalOptions; + +// @ts-expect-error public transition options do not expose internal source bookkeeping +const invalidSource: TransitionOptions = { source: "url" }; + +// @ts-expect-error public transition options do not expose active transition lookup +const invalidCurrent: TransitionOptions = { current: () => null }; + +// @ts-expect-error public transition options do not expose redirect bookkeeping +const invalidRedirect: TransitionOptions = { redirectedFrom: undefined }; + +// @ts-expect-error public transition options do not expose built reload state +const invalidReloadState: TransitionOptions = { reloadState: undefined }; + +const hookResume: HookResult = true; +const hookAbort: HookResult = false; +const hookAsyncResume: HookResult = Promise.resolve(undefined); + +// @ts-expect-error hook results must be explicit transition decisions +const invalidHookResult: HookResult = "resume"; + +void invalidSource; +void invalidCurrent; +void invalidRedirect; +void invalidReloadState; +void hookResume; +void hookAbort; +void hookAsyncResume; +void invalidHookResult; diff --git a/src/router/transition/transition-service.ts b/src/router/transition/transition-service.ts index c0d71cd72..a51254aff 100644 --- a/src/router/transition/transition-service.ts +++ b/src/router/transition/transition-service.ts @@ -1,9 +1,6 @@ -import { - _exceptionHandlerProvider, - _routerProvider, -} from "../../injection-tokens.ts"; +import type { SecurityPolicy } from "../../services/security/security.ts"; import type { TargetState } from "../state/target-state.ts"; -import type { RouterProvider } from "../router.ts"; +import type { RouterRuntimeState } from "../router.ts"; import { Transition } from "./transition.ts"; import type { PathNode } from "../path/path-node.ts"; import { registerHook, type RegisteredHooks } from "./hook-registry.ts"; @@ -13,11 +10,11 @@ import type { HookMatchCriteria, HookRegOptions, HookRegistry, - TransitionOptions, + InternalTransitionOptions, } from "./interface.ts"; import type { TransitionEventType } from "./transition-event-type.ts"; import type { TransitionHookPhaseValue } from "./transition-hook.ts"; -import type { StateProvider } from "../state/state-service.ts"; +import type { StateRuntime } from "../state/state-service.ts"; import type { ViewService } from "../view/view.ts"; import { defineCoreTransitionEvents } from "./transition-events.ts"; import { @@ -26,7 +23,8 @@ import { treeChangesCleanup, } from "./transition-hooks.ts"; -export const defaultTransOpts: TransitionOptions = { +/** @internal */ +export const defaultTransOpts: InternalTransitionOptions = { location: true, relative: undefined, inherit: false, @@ -36,11 +34,7 @@ export const defaultTransOpts: TransitionOptions = { }; /** - * The runtime service instance returned from `TransitionProvider.$get`. - * - * Note: In this codebase, `$get` returns the provider instance (`return this;`), - * so the "service" surface includes both the public HookRegistry API and - * a set of internal fields/methods used by built-in hook registrations. + * Internal transition runtime used by the public `$transitions` service. */ export interface TransitionService extends HookRegistry { /** @@ -71,31 +65,29 @@ export interface TransitionService extends HookRegistry { ): DeregisterFn; /** @internal Wire hooks that require runtime services. */ - _initRuntimeHooks( - stateService: StateProvider, - viewService: ViewService, - ): void; + _initRuntimeHooks(stateService: StateRuntime, viewService: ViewService): void; /** @internal view service */ /** @internal */ _view: ViewService; /** @internal */ - _stateService: StateProvider; + _stateService: StateRuntime; /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; /** @internal */ _exceptionHandler: ng.ExceptionHandlerService; + + /** @internal */ + _security: SecurityPolicy; } /** * Central registry and factory for transition events, hooks, and transition instances. */ -export class TransitionProvider implements TransitionService { - static $inject = [_routerProvider, _exceptionHandlerProvider] as const; - +export class TransitionRuntime implements TransitionService { /** @internal */ _transitionCount: number; /** @internal */ @@ -103,39 +95,35 @@ export class TransitionProvider implements TransitionService { /** @internal */ _registeredHooks: RegisteredHooks; /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; + /** @internal */ + _security: SecurityPolicy; /** @internal */ _view!: ViewService; /** @internal */ - _stateService!: StateProvider; + _stateService!: StateRuntime; /** @internal */ _exceptionHandler: ng.ExceptionHandlerService; constructor( - routerState: RouterProvider, - $exceptionHandler: ng.ExceptionHandlerProvider, + routerState: RouterRuntimeState, + $exceptionHandler: ng.ExceptionHandlerService, + securityPolicy: SecurityPolicy, ) { this._transitionCount = 0; this._eventTypes = []; this._registeredHooks = {}; this._routerState = routerState; + this._security = securityPolicy; defineCoreTransitionEvents(this); this._registerCoreTransitionHooks(); - this._exceptionHandler = $exceptionHandler.handler; + this._exceptionHandler = $exceptionHandler; routerState._successfulTransitionCleanup = treeChangesCleanup; } - /** - * Wires runtime services into the transition service and registers the - * hooks that depend on state/url/view services. - */ - $get(): this { - return this; - } - /** @internal */ _initRuntimeHooks( - stateService: StateProvider, + stateService: StateRuntime, viewService: ViewService, ): void { this._view = viewService; diff --git a/src/router/transition/transition.ts b/src/router/transition/transition.ts index 6a3139f8a..358c80948 100644 --- a/src/router/transition/transition.ts +++ b/src/router/transition/transition.ts @@ -22,16 +22,21 @@ import { } from "../path/path-utils.ts"; import type { Param } from "../params/param.ts"; import { Rejection } from "./reject-factory.ts"; -import type { TransitionOptions, TreeChanges } from "./interface.ts"; +import type { InternalTransitionOptions, TreeChanges } from "./interface.ts"; import type { PathNode } from "../path/path-node.ts"; import type { StateObject } from "../state/state-object.ts"; import type { TargetState } from "../state/target-state.ts"; import type { TransitionService } from "./transition-service.ts"; -import type { StateDeclaration } from "../state/interface.ts"; +import type { + ParamsOf, + StateDeclaration, + RouteMap, +} from "../state/interface.ts"; import type { RawParams } from "../params/interface.ts"; -import type { RouterProvider } from "../router.ts"; +import type { RouterRuntimeState } from "../router.ts"; export type { TransitionOptions } from "./interface.ts"; + const REDIRECT_MAX = 20; interface DeferredPromise { @@ -119,13 +124,23 @@ function collectPathParams(path: PathNode[]): RawParams { * This object contains all contextual information about the to/from states, parameters, resolves. * It has information about all states being entered and exited as a result of the transition. */ -export class Transition { +export class Transition< + TRouteMap extends RouteMap = RouteMap, + TToRouteName extends Extract = Extract< + keyof TRouteMap, + string + >, + TFromRouteName extends Extract = Extract< + keyof TRouteMap, + string + >, +> { promise: Promise; $id: number; /** @internal */ _aborted?: boolean; /** @internal */ - _routerState: RouterProvider; + _routerState: RouterRuntimeState; /** @internal */ _transitionService: TransitionService; /** @internal */ @@ -135,10 +150,10 @@ export class Transition { /** @internal */ _targetState: TargetState; /** @internal */ - _options: TransitionOptions; + _options: InternalTransitionOptions; success: boolean | undefined; /** @internal */ - _error: Rejection | undefined; + _error: Error | undefined; /** * Creates a new Transition object. @@ -157,7 +172,7 @@ export class Transition { fromPath: PathNode[], targetState: TargetState, transitionService: TransitionService, - routerState: RouterProvider, + routerState: RouterRuntimeState, ) { this._routerState = routerState; @@ -169,7 +184,7 @@ export class Transition { * This promise is resolved or rejected based on the outcome of the Transition. * * When the transition is successful, the promise is resolved - * When the transition is unsuccessful, the promise is rejected with the [[Rejection]] or javascript error + * When the transition is unsuccessful, the promise is rejected with a router error object */ this.promise = this._deferred.promise; @@ -179,7 +194,8 @@ export class Transition { throw new Error(targetState.error()); } // current() is assumed to come from targetState.options, but provide a naive implementation otherwise. - this._options = assign({ current: () => this }, targetState.options()); + this._options = assign({}, targetState._options); + this._options.current ??= () => this; this.$id = transitionService._transitionCount++; const toPath = buildToPath(fromPath, targetState); @@ -262,6 +278,9 @@ export class Transition { * @param {string} pathname * @returns {RawParams} */ + params(pathname?: "to"): ParamsOf; + params(pathname: "from"): ParamsOf; + params(pathname: string): RawParams; params(pathname = "to"): RawParams { const path = this._treeChanges[pathname] ?? []; @@ -315,7 +334,7 @@ export class Transition { } trans = trans._options.redirectedFrom ?? null; } - const redirectOpts: TransitionOptions = { + const redirectOpts: InternalTransitionOptions = { redirectedFrom: this, source: "redirect", }; @@ -365,7 +384,7 @@ export class Transition { // Find any "entering" nodes in the redirect path that match the original path and aren't being reloaded const matchingEnteringNodes: PathNode[] = []; - const { reloadState } = targetState.options(); + const { reloadState } = targetState._options; params.forEach((node) => { if (!nodeIsReloading(node, reloadState)) { @@ -538,9 +557,9 @@ export class Transition { * If the transition is invalid (and could not be run), returns the reason the transition is invalid. * If the transition was valid and ran, but was not successful, returns the reason the transition failed. * - * @returns a transition rejection explaining why the transition is invalid, or the reason the transition failed. + * @internal */ - error(): Rejection | undefined { + error(): Error | undefined { const state = this.$to(); if (state.self.abstract) { diff --git a/src/router/url/url-matcher.ts b/src/router/url/url-matcher.ts index 9f2d45953..a1f0e4a61 100644 --- a/src/router/url/url-matcher.ts +++ b/src/router/url/url-matcher.ts @@ -50,20 +50,28 @@ function quoteRegExp(str: string, param?: Param): string { if (!param) return result; + const paramPattern = getParamRoutePattern(param); + switch (param.squash) { case false: - return `${result}(${param.type.pattern.source})${ - param.isOptional ? "?" : "" - }`; + return `${result}(${paramPattern})${param.isOptional ? "?" : ""}`; case true: result = result.replace(/\/$/, ""); - return `${result}(?:/(${param.type.pattern.source})|/)?`; + return `${result}(?:/(${paramPattern})|/)?`; default: - return `${result}(${param.squash}|${param.type.pattern.source})?`; + return `${result}(${param.squash}|${paramPattern})?`; } } +function getParamRoutePattern(param: Param): string { + if (param.location === DefType._PATH && param.type.pattern.ignoreCase) { + return "[\\s\\S]*?"; + } + + return param.type.pattern.source; +} + function pushStaticSegmentWeights(weights: number[], segment: string): void { if (!segment) return; @@ -376,7 +384,7 @@ function getParamType( * path into the parameter 'path'. * * `'/files/*path'` - ditto. * * `'/calendar/{start:date}'` - Matches "/calendar/2014-11-12" (because the pattern defined - * in the built-in `date` ParamType matches `2014-11-12`) and provides a Date object in $stateParams.start + * in the built-in `date` ParamType matches `2014-11-12`) and provides a Date object in `$state.params.start` * */ export class UrlMatcher { @@ -570,7 +578,7 @@ export class UrlMatcher { const param = matcherParams[j]; if (param.location !== DefType._PATH) continue; - const value = param.value(match[pathMatchIndex++]); + const value = param.value(match[pathMatchIndex++], "url"); if (!param.validates(value)) return null; values[param.id] = value; @@ -584,7 +592,7 @@ export class UrlMatcher { const param = matcherParams[j]; if (param.location !== DefType._SEARCH) continue; - const value = param.value(searchParams[param.id]); + const value = param.value(searchParams[param.id], "url"); if (!param.validates(value)) return null; values[param.id] = value; diff --git a/src/router/url/url.spec.ts b/src/router/url/url.spec.ts index 9a317c4e3..65f8a96f4 100644 --- a/src/router/url/url.spec.ts +++ b/src/router/url/url.spec.ts @@ -3,6 +3,7 @@ import { dealoc } from "../../shared/dom.ts"; import { Angular } from "../../angular.ts"; import { compareUrlMatchers, UrlMatcher } from "./url-matcher.ts"; +import { RouterUrlRuntime } from "../router-url.ts"; describe("UrlMatcher", () => { let router; @@ -32,16 +33,13 @@ describe("UrlMatcher", () => { window.angular = new Angular(); window.angular .module("defaultModule", []) - .config( - ($locationProvider) => - ($locationProvider.html5ModeConf.enabled = false), - ); + .config({ $location: { html5Mode: false } }); $injector = window.angular.bootstrap(document.getElementById("app"), [ "defaultModule", ]); - $injector.invoke((_$location_, _$stateRegistry_) => { - router = $injector.get("$$r"); + $injector.invoke((_$location_, _$stateRegistry_, _$state_) => { + router = _$state_._routerState; $location = _$location_; $stateRegistry = _$stateRegistry_; }); @@ -69,6 +67,23 @@ describe("UrlMatcher", () => { }); }); + it("formats hrefs for object and default HTML5 location modes", () => { + const matcher = router._compile("/docs"); + const hashRuntime = new RouterUrlRuntime({ + html5Mode: { enabled: false }, + }); + const booleanHashRuntime = new RouterUrlRuntime({ html5Mode: false }); + const defaultRuntime = new RouterUrlRuntime({}); + + hashRuntime._baseHref = "/"; + booleanHashRuntime._baseHref = "/"; + defaultRuntime._baseHref = "/"; + + expect(hashRuntime._href(matcher, {}, {})).toBe("#!/docs"); + expect(booleanHashRuntime._href(matcher, {}, {})).toBe("#!/docs"); + expect(defaultRuntime._href(matcher, {}, {})).toBe("/docs"); + }); + it("should match static URLs", () => { expect(router._compile("/hello/world")._exec("/hello/world")).toEqual({}); }); @@ -430,6 +445,30 @@ describe("UrlMatcher", () => { expect(m._exec("/FOO/BAR")).toEqual({ param: "BAR" }); }); + it("should preserve case-insensitive custom param type patterns", () => { + router.config({ + paramTypes: { + slug: { + pattern: /s-[a-z]+-[0-9]+/i, + encode(value: string) { + return `s-${value}`; + }, + decode(value: string) { + return String(value).toLowerCase().replace(/^s-/, ""); + }, + is(value: unknown) { + return typeof value === "string" && value.includes("-"); + }, + }, + }, + }); + + const matcher = router._compile("/item/{slug:slug}"); + + expect(matcher._exec("/item/S-abc-12")).toEqual({ slug: "abc-12" }); + expect(matcher._exec("/ITEM/S-abc-12")).toBeNull(); + }); + it("should generate/match params in the proper order", () => { let m = router._compile("/foo?queryparam"); @@ -992,19 +1031,19 @@ describe("UrlMatcher", () => { }); it("should allow injectable functions", () => { - const $stateParams = $injector.get("$stateParams"); + const $state = $injector.get("$state"); const matcher = router._compile("/users/{user:json}", { state: { params: { - user: ($stateParams) => $stateParams.user, + user: ($state) => $state.params.user, }, }, }); const user = { name: "Bob" }; - $stateParams.user = user; + $state.params.user = user; expect(matcher._exec("/users/").user).toBe(user); }); diff --git a/src/router/view-hook.spec.ts b/src/router/view-hook.spec.ts index 0fa33f8ad..135dfb8f0 100644 --- a/src/router/view-hook.spec.ts +++ b/src/router/view-hook.spec.ts @@ -28,12 +28,10 @@ describe("view hooks", () => { window.angular = new Angular(); app = window.angular .module("defaultModule", []) - .config(($stateProvider) => { - $stateProvider.state({ name: "foo", url: "/foo", component: "foo" }); - $stateProvider.state({ name: "bar", url: "/bar", component: "bar" }); - $stateProvider.state({ name: "baz", url: "/baz", component: "baz" }); - $stateProvider.state({ name: "redirect", redirectTo: "baz" }); - }) + .router({ name: "foo", url: "/foo", component: "foo" }) + .router({ name: "bar", url: "/bar", component: "bar" }) + .router({ name: "baz", url: "/baz", component: "baz" }) + .router({ name: "redirect", redirectTo: "baz" }) .component( "foo", Object.assign({}, Object.assign(component, { controller: ctrl })), @@ -72,7 +70,7 @@ describe("view hooks", () => { }; it("can cancel a transition that would exit the view's state by returning false", async () => { - $state.defaultErrorHandler(function () {}); + $state._defaultErrorHandler = function () {}; ctrl.prototype.$canExit = function () { log += "canexit;"; @@ -80,6 +78,7 @@ describe("view hooks", () => { }; await initial(); $state.go("bar"); + await waitForState("foo", "canexit;"); expect(log).toBe("canexit;"); expect($state.current.name).toBe("foo"); }); @@ -132,7 +131,7 @@ describe("view hooks", () => { }; await initial(); - $state.defaultErrorHandler(function () {}); + $state._defaultErrorHandler = function () {}; $state.go("bar"); await waitForState("foo", "canexit;"); expect(log).toBe("canexit;"); @@ -140,7 +139,7 @@ describe("view hooks", () => { }); it("can wait for a promise and then reject the transition", async () => { - $state.defaultErrorHandler(function () {}); + $state._defaultErrorHandler = function () {}; ctrl.prototype.$canExit = function () { log += "canexit;"; diff --git a/src/router/view/invocation-context.spec.ts b/src/router/view/invocation-context.spec.ts new file mode 100644 index 000000000..b65d1e30f --- /dev/null +++ b/src/router/view/invocation-context.spec.ts @@ -0,0 +1,36 @@ +import { createRouterViewControllerInvocationLocals } from "./invocation-context.ts"; + +describe("router view controller invocation context", () => { + it("combines route resolves with the public scope and element locals", () => { + const scope = {} as ng.Scope; + const element = document.createElement("ng-view"); + + expect( + createRouterViewControllerInvocationLocals( + { user: { id: 42 } }, + scope, + element, + ), + ).toEqual({ + user: { id: 42 }, + $scope: scope, + $element: element, + }); + }); + + it("reserves $scope and $element for framework controller locals", () => { + const scope = {} as ng.Scope; + const element = document.createElement("ng-view"); + + expect( + createRouterViewControllerInvocationLocals( + { $scope: "resolve scope", $element: "resolve element" }, + scope, + element, + ), + ).toEqual({ + $scope: scope, + $element: element, + }); + }); +}); diff --git a/src/router/view/invocation-context.ts b/src/router/view/invocation-context.ts new file mode 100644 index 000000000..6cbfd98cf --- /dev/null +++ b/src/router/view/invocation-context.ts @@ -0,0 +1,16 @@ +import type { ResolveInvocationLocals } from "../resolve/resolve-context.ts"; + +/** @internal */ +export type RouterViewControllerInvocationLocals = ResolveInvocationLocals & { + $scope: ng.Scope; + $element: HTMLElement; +}; + +/** @internal */ +export function createRouterViewControllerInvocationLocals( + resolves: ResolveInvocationLocals | undefined, + scope: ng.Scope, + element: HTMLElement, +): RouterViewControllerInvocationLocals { + return { ...resolves, $scope: scope, $element: element }; +} diff --git a/src/router/view/view.html b/src/router/view/view.html index 4a6fb94e6..5d9d398ad 100644 --- a/src/router/view/view.html +++ b/src/router/view/view.html @@ -11,6 +11,10 @@ + diff --git a/src/router/view/view.spec.ts b/src/router/view/view.spec.ts index 786c4735e..6481fec26 100644 --- a/src/router/view/view.spec.ts +++ b/src/router/view/view.spec.ts @@ -10,25 +10,22 @@ import { PathNode } from "../path/path-node.ts"; import { applyViewConfigs } from "../path/path-utils.ts"; describe("view", () => { - let $injector, routerState, root, states; + let $injector, routerState, root, stateService, states; beforeEach(() => { dealoc(document.getElementById("app")); window.angular = new Angular(); - window.angular - .module("defaultModule", []) - .config(function (_$provide_, _$$rProvider_) { - _$provide_.factory("foo", () => { - return "Foo"; - }); - routerState = _$$rProvider_; - }); + window.angular.module("defaultModule", []).factory("foo", () => { + return "Foo"; + }); $injector = window.angular.bootstrap(document.getElementById("app"), [ "defaultModule", ]); $injector.invoke((_$injector_) => { $injector = _$injector_; + stateService = $injector.get("$state"); + routerState = stateService._routerState; states = {}; const matcher = new StateMatcher(states); @@ -51,6 +48,29 @@ describe("view", () => { }; } + function createTestViewService(viewRouterState = routerState) { + return new ViewService({ + compileLifecycle: { + onControllerCreated: () => () => undefined, + }, + templateFactory: stateService._viewService._templateFactory, + routerState: viewRouterState, + transitions: $injector.get("$transitions"), + compile: $injector.get("$compile"), + controller: $injector.get("$controller"), + rootScope: { + $on: () => () => undefined, + }, + injector: $injector, + }); + } + + it("starts without a root context before router state initialization", () => { + const viewService = createTestViewService({ _currentState: undefined }); + + expect(viewService._rootContext).toBeNull(); + }); + describe("matching", () => { it("matches root default ng-view targets using the active ng-view fqn format", () => { const ngView = { @@ -139,9 +159,7 @@ describe("view", () => { template: "test", }); - const viewService = new ViewService(); - - viewService._templateFactory = $injector.get("$templateFactory"); + const viewService = createTestViewService(); const path = [root, state].map((_state) => new PathNode(_state)); @@ -152,11 +170,467 @@ describe("view", () => { expect(path[1]._views[0]._viewDecl).toBe(state._views.$default); }); + it("destroys retained views when the root scope is destroyed", () => { + const viewService = stateService._viewService; + const retainedElement = document.createElement("section"); + const retainedScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy").and.callFake(() => { + retainedScope.$handler._destroyed = true; + }), + }; + + viewService._retainedViews.set("retained", { + _key: "retained", + _config: { + _targetKey: "retained.$default", + _retention: { + _mode: "keep-alive", + _key: "retained", + _state: "retained", + }, + }, + _element: retainedElement, + _nodes: [retainedElement], + _scope: retainedScope, + _animation: {}, + _createdAt: 1, + _lastUsed: 1, + }); + + $injector.get("$rootScope").$destroy(); + + expect(retainedScope.$destroy).toHaveBeenCalled(); + expect(viewService._retainedViews.size).toBe(0); + expect(viewService._retentionDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "destroyed", + _key: "retained", + _state: "retained", + _targetKey: "retained.$default", + _cacheSize: 1, + _reason: "root-destroy", + }), + ]); + }); + + it("records retained view restore diagnostics", () => { + const viewService = createTestViewService(); + const retainedElement = document.createElement("section"); + const retainedScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy"), + }; + const config = { + _targetKey: "retained.$default", + _retention: { + _mode: "keep-alive", + _key: "retained", + _state: "retained", + _max: 4, + }, + }; + + viewService._retainView({ + _key: "retained", + _config: config, + _element: retainedElement, + _nodes: [retainedElement], + _scope: retainedScope, + _animation: {}, + }); + + const restored = viewService._restoreRetainedView(config); + + expect(restored?._scope).toBe(retainedScope); + expect(viewService._retainedViews.size).toBe(0); + expect(viewService._retentionDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retained", + _key: "retained", + _state: "retained", + _targetKey: "retained.$default", + _cacheSize: 1, + _max: 4, + }), + jasmine.objectContaining({ + _kind: "restored", + _key: "retained", + _state: "retained", + _targetKey: "retained.$default", + _cacheSize: 0, + }), + ]); + }); + + it("records retained view eviction diagnostics", () => { + const viewService = createTestViewService(); + const firstElement = document.createElement("section"); + const secondElement = document.createElement("section"); + const firstScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy").and.callFake(() => { + firstScope.$handler._destroyed = true; + }), + }; + const secondScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy"), + }; + const firstConfig = { + _targetKey: "retainedA.$default", + _retention: { + _mode: "keep-alive", + _key: "retained-a", + _state: "retainedA", + _max: 1, + }, + }; + const secondConfig = { + _targetKey: "retainedB.$default", + _retention: { + _mode: "keep-alive", + _key: "retained-b", + _state: "retainedB", + _max: 1, + }, + }; + + viewService._retainView({ + _key: "retained-a", + _config: firstConfig, + _element: firstElement, + _nodes: [firstElement], + _scope: firstScope, + _animation: {}, + }); + + viewService._retainView({ + _key: "retained-b", + _config: secondConfig, + _element: secondElement, + _nodes: [secondElement], + _scope: secondScope, + _animation: {}, + }); + + expect(firstScope.$destroy).toHaveBeenCalled(); + expect(viewService._retainedViews.has("retained-a")).toBe(false); + expect(viewService._retainedViews.has("retained-b")).toBe(true); + expect(viewService._retentionDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "retained", + _key: "retained-a", + _cacheSize: 1, + _max: 1, + }), + jasmine.objectContaining({ + _kind: "retained", + _key: "retained-b", + _cacheSize: 2, + _max: 1, + }), + jasmine.objectContaining({ + _kind: "destroyed", + _key: "retained-a", + _cacheSize: 1, + _reason: "evicted", + }), + ]); + }); + + it("destroys views that are no longer configured for keep-alive retention", () => { + const viewService = createTestViewService(); + const retainedElement = document.createElement("section"); + const retainedScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy").and.callFake(() => { + retainedScope.$handler._destroyed = true; + }), + }; + + viewService._retainView({ + _key: "destroyed-mode", + _config: { + _targetKey: "destroyedMode.$default", + _retention: { + _mode: "destroy", + _key: "destroyed-mode", + _state: "destroyedMode", + }, + }, + _element: retainedElement, + _nodes: [retainedElement], + _scope: retainedScope, + _animation: {}, + }); + + expect(retainedScope.$destroy).toHaveBeenCalled(); + expect(viewService._retainedViews.size).toBe(0); + expect(viewService._retentionDiagnostics).toEqual([ + jasmine.objectContaining({ + _kind: "destroyed", + _key: "destroyed-mode", + _reason: "mode-destroy", + }), + ]); + }); + + it("destroys an existing retained view when a key is replaced", () => { + const viewService = createTestViewService(); + const firstElement = document.createElement("section"); + const secondElement = document.createElement("section"); + const firstScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy").and.callFake(() => { + firstScope.$handler._destroyed = true; + }), + }; + const secondScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy"), + }; + const config = { + _targetKey: "replace.$default", + _retention: { + _mode: "keep-alive", + _key: "replace", + _state: "replace", + }, + }; + + viewService._retainView({ + _key: "replace", + _config: config, + _element: firstElement, + _nodes: [firstElement], + _scope: firstScope, + _animation: {}, + }); + viewService._retainView({ + _key: "replace", + _config: config, + _element: secondElement, + _nodes: [secondElement], + _scope: secondScope, + _animation: {}, + }); + + expect(firstScope.$destroy).toHaveBeenCalled(); + expect(viewService._retainedViews.get("replace")._scope).toBe( + secondScope, + ); + expect(viewService._retentionDiagnostics).toContain( + jasmine.objectContaining({ + _kind: "destroyed", + _key: "replace", + _reason: "replaced", + }), + ); + }); + + it("removes retained elements that are still attached to the DOM", () => { + const viewService = createTestViewService(); + const host = document.createElement("div"); + const retainedElement = document.createElement("section"); + const retainedScope = { + $handler: { + _destroyed: false, + }, + $destroy: jasmine.createSpy("$destroy").and.callFake(() => { + retainedScope.$handler._destroyed = true; + }), + }; + + host.appendChild(retainedElement); + document.body.appendChild(host); + + try { + viewService._destroyRetainedView({ + _key: "attached", + _config: { + _targetKey: "attached.$default", + _retention: { + _mode: "keep-alive", + _key: "attached", + _state: "attached", + }, + }, + _element: retainedElement, + _scope: retainedScope, + }); + + expect(retainedScope.$destroy).toHaveBeenCalled(); + expect(host.contains(retainedElement)).toBe(false); + } finally { + dealoc(host); + } + }); + + it("leaves retained views untouched when eviction is disabled", () => { + const viewService = createTestViewService(); + + viewService._retainedViews.set("disabled", { + _key: "disabled", + _config: { + _targetKey: "disabled.$default", + _retention: { + _mode: "keep-alive", + _key: "disabled", + _state: "disabled", + }, + }, + _element: document.createElement("section"), + _nodes: [], + _scope: { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }, + _animation: {}, + _createdAt: 1, + _lastUsed: 1, + }); + + viewService._evictRetainedViews(undefined, "lru"); + viewService._evictRetainedViews(-1, "lru"); + + expect(viewService._retainedViews.has("disabled")).toBe(true); + }); + + it("leaves retained views untouched when eviction selection returns nothing", () => { + const viewService = createTestViewService(); + const retainedScope = { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }; + + viewService._retainedViews.set("unselected", { + _key: "unselected", + _config: { + _targetKey: "unselected.$default", + _retention: { + _mode: "keep-alive", + _key: "unselected", + _state: "unselected", + }, + }, + _element: document.createElement("section"), + _nodes: [], + _scope: retainedScope, + _animation: {}, + _createdAt: 1, + _lastUsed: 1, + }); + spyOn(viewService, "_selectOrderedRetainedView").and.returnValue( + undefined, + ); + + viewService._evictRetainedViews(0, "lru"); + + expect(viewService._retainedViews.has("unselected")).toBe(true); + expect(retainedScope.$destroy).not.toHaveBeenCalled(); + }); + + it("falls back when a policy eviction target is not selected", () => { + const viewService = createTestViewService(); + const invoke = jasmine.createSpy("invoke").and.returnValue(undefined); + viewService._injector = { invoke }; + viewService._retainedViews.set("policy", { + _key: "policy", + _config: { + _targetKey: "policy.$default", + _path: [{ state: { self: { name: "policy" } } }], + _retention: { + _mode: "keep-alive", + _key: "policy", + _state: "policy", + }, + }, + _element: document.createElement("section"), + _nodes: [], + _scope: { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }, + _animation: {}, + _createdAt: 1, + _lastUsed: 1, + }); + + expect(viewService._selectPolicyRetainedView(1, "evictPolicy")).toBe( + undefined, + ); + expect(invoke).toHaveBeenCalled(); + }); + + it("selects the least recently used retained view when ordered fallback changes", () => { + const viewService = createTestViewService(); + const first = { + _key: "first", + _config: { + _targetKey: "first.$default", + _retention: { + _mode: "keep-alive", + _key: "first", + _state: "first", + }, + }, + _element: document.createElement("section"), + _nodes: [], + _scope: { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }, + _animation: {}, + _createdAt: 1, + _lastUsed: 5, + }; + const second = { + _key: "second", + _config: { + _targetKey: "second.$default", + _retention: { + _mode: "keep-alive", + _key: "second", + _state: "second", + }, + }, + _element: document.createElement("section"), + _nodes: [], + _scope: { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }, + _animation: {}, + _createdAt: 2, + _lastUsed: 1, + }; + + viewService._retainedViews.set(first._key, first); + viewService._retainedViews.set(second._key, second); + + expect(viewService._selectOrderedRetainedView("lru")).toBe(second); + }); + it("indexes active view configs by normalized target", () => { const parent = register({ name: "parent", parent: root }); const child = register({ name: "child", parent }); const path = [root, parent, child].map((_state) => new PathNode(_state)); - const viewService = new ViewService(); + const viewService = createTestViewService(); const config = createViewConfig( path, { @@ -165,7 +639,7 @@ describe("view", () => { _context: child, template: "sidebar", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); expect(config._targetKey).toBe("parent.sidebar"); @@ -191,7 +665,7 @@ describe("view", () => { const childPath = [root, parent, child].map( (_state) => new PathNode(_state), ); - const viewService = new ViewService(); + const viewService = createTestViewService(); const defaultConfig = createViewConfig( defaultPath, { @@ -200,7 +674,7 @@ describe("view", () => { _context: parent, template: "default", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const sidebarConfig = createViewConfig( childPath, @@ -210,7 +684,7 @@ describe("view", () => { _context: child, template: "sidebar", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const ngView = { _id: 0, @@ -238,7 +712,7 @@ describe("view", () => { it("does not notify an ng-view when the selected config is unchanged", () => { const parent = register({ name: "parent", parent: root }); const path = [root, parent].map((_state) => new PathNode(_state)); - const viewService = new ViewService(); + const viewService = createTestViewService(); const config = createViewConfig( path, { @@ -247,7 +721,7 @@ describe("view", () => { _context: parent, template: "default", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const ngView = { _id: 0, @@ -270,7 +744,7 @@ describe("view", () => { it("notifies an ng-view when its selected config is deactivated", () => { const parent = register({ name: "parent", parent: root }); const path = [root, parent].map((_state) => new PathNode(_state)); - const viewService = new ViewService(); + const viewService = createTestViewService(); const config = createViewConfig( path, { @@ -279,7 +753,7 @@ describe("view", () => { _context: parent, template: "default", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const ngView = { _id: 0, @@ -304,7 +778,7 @@ describe("view", () => { const parent = register({ name: "parent", parent: root }); const child = register({ name: "child", parent }); const path = [root, parent, child].map((_state) => new PathNode(_state)); - const viewService = new ViewService(); + const viewService = createTestViewService(); const sidebarConfig = createViewConfig( path, { @@ -313,7 +787,7 @@ describe("view", () => { _context: child, template: "sidebar", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const ngView = { _id: 0, @@ -340,7 +814,7 @@ describe("view", () => { const childPath = [root, parent, child].map( (_state) => new PathNode(_state), ); - const viewService = new ViewService(); + const viewService = createTestViewService(); const parentConfig = createViewConfig( parentPath, { @@ -349,7 +823,7 @@ describe("view", () => { _context: parent, template: "parent", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const childConfig = createViewConfig( childPath, @@ -359,7 +833,7 @@ describe("view", () => { _context: child, template: "child", }, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); const ngView = { _id: 0, @@ -385,6 +859,51 @@ describe("view", () => { expect(ngView._config).toBe(childConfig); }); + it("replaces stale same-depth configs for the same view target", () => { + const parent = register({ name: "parent", parent: root }); + const firstChild = register({ name: "firstChild", parent }); + const secondChild = register({ name: "secondChild", parent }); + const firstPath = [root, parent, firstChild].map( + (_state) => new PathNode(_state), + ); + const secondPath = [root, parent, secondChild].map( + (_state) => new PathNode(_state), + ); + const viewService = createTestViewService(); + const firstConfig = createViewConfig( + firstPath, + { + _ngViewName: "$default", + _ngViewContextAnchor: "parent", + _context: firstChild, + template: "first", + }, + stateService._viewService._templateFactory, + ); + const secondConfig = createViewConfig( + secondPath, + { + _ngViewName: "$default", + _ngViewContextAnchor: "parent", + _context: secondChild, + template: "second", + }, + stateService._viewService._templateFactory, + ); + + expect(firstConfig._targetKey).toBe("parent.$default"); + expect(secondConfig._targetKey).toBe("parent.$default"); + expect(firstConfig._depth).toBe(secondConfig._depth); + + viewService._activateViewConfig(firstConfig); + viewService._activateViewConfig(secondConfig); + + expect(viewService._viewConfigs).toEqual([secondConfig]); + expect(viewService._viewConfigsByTarget.get("parent.$default")).toEqual([ + secondConfig, + ]); + }); + it("caches component fill details on view configs", async () => { const state = register({ name: "withComponent", @@ -396,7 +915,7 @@ describe("view", () => { const config = createViewConfig( path, state._views.$default, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); expect(config._fillPlan._kind).toBe("component"); @@ -426,7 +945,7 @@ describe("view", () => { const config = createViewConfig( path, state._views.$default, - $injector.get("$templateFactory"), + stateService._viewService._templateFactory, ); expect(config._fillPlan._kind).toBe("template"); diff --git a/src/router/view/view.ts b/src/router/view/view.ts index 0ef6850fb..77f01289e 100644 --- a/src/router/view/view.ts +++ b/src/router/view/view.ts @@ -1,32 +1,47 @@ -import { - _compile, - _compileLifecycle, - _controller, - _injector, - _router, - _templateFactory, - _transitions, -} from "../../injection-tokens.ts"; -import { setCacheData } from "../../shared/dom.ts"; +import { dealoc, removeElement, setCacheData } from "../../shared/dom.ts"; import { removeFrom } from "../../shared/common.ts"; import { assign, assertDefined, isString } from "../../shared/utils.ts"; -import type { - CompileControllerLifecycleRecord, - CompileLifecycleProvider, -} from "../../core/compile/compile.ts"; +import type { CompileControllerLifecycleRecord } from "../../core/compile/compile.ts"; +import type { CompiledFragmentRecord } from "../../core/compile/incremental-fragment.ts"; import { registerViewControllerCallbacks, type ViewControllerInstance, } from "../directives/view-controller-hooks.ts"; -import { ResolveContext } from "../resolve/resolve-context.ts"; +import { + createResolveInvocationLocals, + ResolveContext, +} from "../resolve/resolve-context.ts"; import { kebobString } from "../../shared/strings.ts"; import type { RawParams } from "../params/interface.ts"; import type { PathNode } from "../path/path-node.ts"; -import type { ViewDeclaration } from "../state/interface.ts"; +import type { + StateRetentionEvictionContext, + StateRetentionEvictionPolicy, + ViewDeclaration, +} from "../state/interface.ts"; import type { StateObject } from "../state/state-object.ts"; -import type { TemplateFactoryProvider } from "../router/template-factory.ts"; -import type { RouterProvider } from "../router.ts"; -import { getLocals } from "../state/state-registry.ts"; +import type { TemplateFactoryService } from "../router/template-factory.ts"; +import type { RouterRuntimeState } from "../router.ts"; +import { createRetentionEvictionPolicyInvocationLocals } from "../invocation-context.ts"; +import { createRouterViewControllerInvocationLocals } from "./invocation-context.ts"; + +interface CompileLifecycleSource { + onControllerCreated( + listener: (record: CompileControllerLifecycleRecord) => void, + ): () => void; +} + +/** Dependencies used by router composition to initialize one view service. */ +export interface ViewServiceDependencies { + compileLifecycle: CompileLifecycleSource; + templateFactory: TemplateFactoryService; + routerState: RouterRuntimeState; + transitions: ng.TransitionsService; + compile: ng.CompileService; + controller: ng.ControllerService; + rootScope: ng.Scope; + injector: ng.InjectorService; +} export { normalizeNgViewTarget } from "../state/view-target.ts"; export interface ViewContext { @@ -57,7 +72,7 @@ export interface ViewConfig { _id: number; _path: PathNode[]; _viewDecl: ViewDeclaration; - _factory: TemplateFactoryProvider; + _factory: TemplateFactoryService; _component: string | undefined; _template: string | undefined; _loaded: boolean; @@ -65,6 +80,51 @@ export interface ViewConfig { _fillPlan: ViewFillPlan; _targetKey: string; _depth: number; + _retention?: ViewRetentionAssignment; +} + +/** @internal */ +export interface ViewRetentionAssignment { + _mode: "destroy" | "keep-alive"; + _key: string; + _max?: number; + _pause?: "none" | "background" | "schedulers"; + _evict?: "lru" | "oldest" | StateRetentionEvictionPolicy; + _state: string; +} + +/** @internal */ +export interface RetainedViewEntry { + _key: string; + _config: ViewConfig; + _element: HTMLElement; + _nodes: Node[]; + _fragment?: CompiledFragmentRecord; + _scope: ng.Scope; + _animation: NgViewAnimData; + _createdAt: number; + _lastUsed: number; +} + +/** @internal */ +export type ViewRetentionDiagnosticKind = "retained" | "restored" | "destroyed"; + +/** @internal */ +export type ViewRetentionDestroyReason = + | "evicted" + | "mode-destroy" + | "replaced" + | "root-destroy"; + +/** @internal */ +export interface ViewRetentionDiagnostic { + _kind: ViewRetentionDiagnosticKind; + _key: string; + _state: string | undefined; + _targetKey: string | undefined; + _cacheSize: number; + _max?: number; + _reason?: ViewRetentionDestroyReason; } /** @internal */ @@ -154,7 +214,7 @@ function viewDeclDepth(viewDecl: ViewDeclaration): number { export function createViewConfig( path: PathNode[], viewDecl: ViewDeclaration, - factory: TemplateFactoryProvider, + factory: TemplateFactoryService, ): ViewConfig { return { _id: nextViewId++, @@ -168,6 +228,7 @@ export function createViewConfig( _fillPlan: createViewFillPlan(viewDecl, undefined), _targetKey: viewDeclTargetKey(viewDecl), _depth: viewDeclDepth(viewDecl), + _retention: undefined, }; } @@ -204,6 +265,7 @@ export async function loadViewConfig(config: ViewConfig): Promise { config._controller = config._viewDecl.controller; assign(config, viewResult); + config._loaded = true; config._fillPlan = createViewFillPlan(config._viewDecl, config._component); return config; @@ -251,17 +313,17 @@ export class ViewService { /** @internal */ _viewConfigsByTarget: Map; /** @internal */ - _templateFactory: TemplateFactoryProvider | undefined; + _templateFactory: TemplateFactoryService; /** @internal */ - _compile: ng.CompileService | undefined; + _compile: ng.CompileService; /** @internal */ - _controller: ng.ControllerService | undefined; + _controller: ng.ControllerService; /** @internal */ - _injector: ng.InjectorService | undefined; + _injector: ng.InjectorService; /** @internal */ _rootContext: StateObject | null | undefined; /** @internal */ - _transitions: ng.TransitionService | undefined; + _transitions: ng.TransitionsService; /** @internal */ _componentContexts: Map; /** @internal */ @@ -270,62 +332,56 @@ export class ViewService { _filledHosts: WeakSet; /** @internal */ _deregisterCompileLifecycle: (() => void) | undefined; + /** @internal */ + _deregisterRootDestroy: (() => void) | undefined; + /** @internal */ + _retainedViews: Map; + /** @internal */ + _retainedViewClock: number; + /** @internal */ + _retentionDiagnostics: ViewRetentionDiagnostic[]; /** - * Creates an empty view registry ready to track active `ng-view` instances. + * Creates a fully initialized view registry for one router runtime. */ - constructor() { + constructor(dependencies: ViewServiceDependencies) { this._ngViews = []; this._viewConfigs = []; this._viewConfigsByTarget = new Map(); - this._templateFactory = undefined; - this._compile = undefined; - this._controller = undefined; - this._injector = undefined; - this._rootContext = undefined; - this._transitions = undefined; + this._templateFactory = dependencies.templateFactory; + this._compile = dependencies.compile; + this._controller = dependencies.controller; + this._injector = dependencies.injector; + this._rootContext = dependencies.routerState._currentState ?? null; + this._transitions = dependencies.transitions; this._componentContexts = new Map(); this._nextComponentContextId = 0; this._filledHosts = new WeakSet(); this._deregisterCompileLifecycle = undefined; - } - - /** - * Returns the singleton view service instance. - */ - $get = [ - _templateFactory, - _router, - _compileLifecycle, - _transitions, - _compile, - _controller, - _injector, - ( - $templateFactory: TemplateFactoryProvider, - $routerState: RouterProvider, - $compileLifecycle: CompileLifecycleProvider, - $transitions: ng.TransitionService, - $compile: ng.CompileService, - $controller: ng.ControllerService, - $injector: ng.InjectorService, - ): ViewService => { - this._templateFactory = $templateFactory; - this._compile = $compile; - this._controller = $controller; - this._injector = $injector; - this._rootViewContext($routerState._currentState ?? null); - this._transitions = $transitions; + this._deregisterRootDestroy = undefined; + this._retainedViews = new Map(); + this._retainedViewClock = 0; + this._retentionDiagnostics = []; + this._deregisterCompileLifecycle = + dependencies.compileLifecycle.onControllerCreated((record) => { + this._componentControllerCreated(record); + }); + this._deregisterRootDestroy = dependencies.rootScope.$on("$destroy", () => { + this._destroyRetainedViews(); this._deregisterCompileLifecycle?.(); - this._deregisterCompileLifecycle = $compileLifecycle.onControllerCreated( - (record) => { - this._componentControllerCreated(record); - }, - ); + this._deregisterCompileLifecycle = undefined; + this._deregisterRootDestroy = undefined; + }); + } - return this; - }, - ]; + /** @internal */ + destroy(): void { + this._destroyRetainedViews(); + this._deregisterCompileLifecycle?.(); + this._deregisterCompileLifecycle = undefined; + this._deregisterRootDestroy?.(); + this._deregisterRootDestroy = undefined; + } /** @internal */ _fillView(options: ViewFillOptions): void { @@ -373,7 +429,9 @@ export class ViewService { (host as HTMLIFrameElement).contentDocument ?? host.childNodes, ); - const locals = resolveContext ? getLocals(resolveContext) : undefined; + const locals = resolveContext + ? createResolveInvocationLocals(resolveContext) + : undefined; const targetScope = scope.$target as Record; @@ -385,7 +443,7 @@ export class ViewService { const controllerConfig = assertDefined(config); const controllerInstance = assertDefined(this._controller)( controller, - assign({}, locals, { $scope: scope, $element: host }), + createRouterViewControllerInvocationLocals(locals, scope, host), ) as ViewControllerInstance; setCacheData(host, "$ngControllerController", controllerInstance); @@ -455,8 +513,6 @@ export class ViewService { record.element.removeAttribute(COMPONENT_CONTEXT_ATTR); this._componentContexts.delete(id); - if (!this._transitions) return; - registerViewControllerCallbacks( this._transitions, record.controller as Record, @@ -465,6 +521,200 @@ export class ViewService { ); } + /** @internal */ + _restoreRetainedView(config: ViewConfig): RetainedViewEntry | undefined { + const retention = config._retention; + + if (retention?._mode !== "keep-alive") return undefined; + + const retained = this._retainedViews.get(retention._key); + + if (!retained) return undefined; + + this._retainedViews.delete(retention._key); + retained._lastUsed = ++this._retainedViewClock; + retained._config = config; + this._recordRetentionDiagnostic("restored", retained, { + _cacheSize: this._retainedViews.size, + }); + + return retained; + } + + /** @internal */ + _retainView( + entry: Omit, + ): void { + const retention = entry._config._retention; + + if (retention?._mode !== "keep-alive") { + this._destroyRetainedView(entry, "mode-destroy"); + + return; + } + + const existing = this._retainedViews.get(retention._key); + + if (existing) { + this._destroyRetainedView(existing, "replaced"); + } + + const clock = ++this._retainedViewClock; + + this._retainedViews.set(retention._key, { + ...entry, + _createdAt: clock, + _lastUsed: clock, + }); + this._recordRetentionDiagnostic("retained", entry, { + _cacheSize: this._retainedViews.size, + _max: retention._max, + }); + + this._evictRetainedViews(retention._max, retention._evict); + } + + /** @internal */ + _destroyRetainedView( + entry: Pick & + Partial>, + reason?: ViewRetentionDestroyReason, + ): void { + if (!entry._scope.$handler._destroyed) { + entry._scope.$destroy(); + } + + if (entry._fragment && !entry._fragment.disposed) { + entry._fragment.dispose(); + } else if (entry._element.parentNode) { + removeElement(entry._element); + } else { + dealoc(entry._element); + } + + const key = entry._key; + const config = entry._config; + + if (key && config) { + this._recordRetentionDiagnostic( + "destroyed", + { _key: key, _config: config }, + { + _cacheSize: this._retainedViews.size, + _reason: reason, + }, + ); + } + } + + /** @internal */ + _destroyRetainedViews(): void { + this._retainedViews.forEach((entry) => { + this._destroyRetainedView(entry, "root-destroy"); + }); + this._retainedViews.clear(); + } + + /** @internal */ + _evictRetainedViews( + max: number | undefined, + evict: ViewRetentionAssignment["_evict"], + ): void { + if (max === undefined || max < 0) return; + + while (this._retainedViews.size > max) { + const selected = + this._selectPolicyRetainedView(max, evict) ?? + this._selectOrderedRetainedView(evict === "oldest" ? "oldest" : "lru"); + + if (!selected) return; + + this._retainedViews.delete(selected._key); + this._destroyRetainedView(selected, "evicted"); + } + } + + /** @internal */ + _recordRetentionDiagnostic( + kind: ViewRetentionDiagnosticKind, + entry: Pick, + options: { + _cacheSize: number; + _max?: number; + _reason?: ViewRetentionDestroyReason; + }, + ): void { + this._retentionDiagnostics.push({ + _kind: kind, + _key: entry._key, + _state: entry._config._retention?._state, + _targetKey: entry._config._targetKey, + _cacheSize: options._cacheSize, + _max: options._max, + _reason: options._reason, + }); + } + + /** @internal */ + _selectPolicyRetainedView( + max: number, + evict: ViewRetentionAssignment["_evict"], + ): RetainedViewEntry | undefined { + if (!evict || evict === "lru" || evict === "oldest") return undefined; + + for (const entry of this._retainedViews.values()) { + const state = + entry._config._path[entry._config._path.length - 1].state.self; + + const context: StateRetentionEvictionContext = { + state, + key: entry._key, + size: this._retainedViews.size, + max, + }; + + const result = this._injector.invoke( + evict, + undefined, + createRetentionEvictionPolicyInvocationLocals(context), + "retention eviction policy", + ); + + if (!isString(result)) continue; + + const selected = this._retainedViews.get(result); + + if (selected) return selected; + } + + return undefined; + } + + /** @internal */ + _selectOrderedRetainedView( + evict: "lru" | "oldest", + ): RetainedViewEntry | undefined { + let selected: RetainedViewEntry | undefined; + + this._retainedViews.forEach((entry) => { + if (!selected) { + selected = entry; + + return; + } + + const left = evict === "oldest" ? entry._createdAt : entry._lastUsed; + const right = + evict === "oldest" ? selected._createdAt : selected._lastUsed; + + if (left < right) { + selected = entry; + } + }); + + return selected; + } + /** * Gets or sets the root view context used for relative `ng-view` targeting. */ @@ -498,6 +748,15 @@ export class ViewService { */ /** @internal */ _activateViewConfig(viewConfig: ViewConfig): void { + const existingTargetConfigs = + this._viewConfigsByTarget.get(viewConfig._targetKey) ?? []; + + existingTargetConfigs.slice().forEach((existing) => { + if (existing !== viewConfig && existing._depth === viewConfig._depth) { + this._deactivateViewConfig(existing); + } + }); + this._viewConfigs.push(viewConfig); let targetConfigs = this._viewConfigsByTarget.get(viewConfig._targetKey); diff --git a/src/runtime/custom-counter.html b/src/runtime/custom-counter.html index 5d3421f34..b5a1c5784 100644 --- a/src/runtime/custom-counter.html +++ b/src/runtime/custom-counter.html @@ -29,16 +29,14 @@
- +
diff --git a/src/services/event-bus/event-bus.spec.ts b/src/services/event-bus/event-bus.spec.ts new file mode 100644 index 000000000..db2a5b9f2 --- /dev/null +++ b/src/services/event-bus/event-bus.spec.ts @@ -0,0 +1,1134 @@ +// @ts-nocheck +/// +import { + applyEventBusConfiguration, + createEventBusRuntimeState, + createEventBusService, + destroyEventBusRuntimeState, + EventBus, +} from "./event-bus.ts"; +import { createInjector } from "../../core/di/injector.ts"; +import { createScope } from "../../core/scope/scope.ts"; +import { Angular } from "../../angular.ts"; +import { wait } from "../../shared/utils.ts"; +import { createAngular } from "../../runtime/index.ts"; +import { eventBusModule } from "../../runtime/event-bus.ts"; + +describe("EventBus composition", () => { + it("should be injectable", () => { + const angular = new Angular(); + + angular.module("test", ["ng"]); + const $injector = createInjector(["test"]); + + expect($injector.has("$eventBus")).toBeTrue(); + expect($injector.get("$eventBus") instanceof EventBus).toBeTrue(); + }); + + it("applies event bus delivery policy from module config", async () => { + const angular = new Angular(); + const policy = jasmine.createSpy("deliveryPolicy").and.returnValue("drop"); + + window.angular = angular; + angular.module("configuredEventBusPolicy", ["ng"]).config({ + $eventBus: { + deliveryPolicy: policy, + }, + }); + const $injector = createInjector(["configuredEventBusPolicy"]); + const $eventBus = $injector.get("$eventBus"); + const listener = jasmine.createSpy("listener"); + + $eventBus.subscribe("configured:topic", listener); + + expect($eventBus.publish("configured:topic", "payload")).toBe(true); + await wait(); + + expect(listener).not.toHaveBeenCalled(); + expect(policy).toHaveBeenCalledWith( + jasmine.objectContaining({ + operation: "event.delivery", + topic: "configured:topic", + args: ["payload"], + }), + ); + }); + + it("shares one application EventBus across injectors and runtime teardown", () => { + const angular = new Angular(); + + angular.module("firstEventBusInjector", ["ng"]); + angular.module("secondEventBusInjector", ["ng"]); + + const first = createInjector(["firstEventBusInjector"]).get("$eventBus"); + const second = createInjector(["secondEventBusInjector"]).get("$eventBus"); + + expect(second).toBe(first); + expect(angular.$eventBus).toBe(first); + + angular._composition.destroy(); + + expect(first.isDisposed()).toBeTrue(); + }); + + it("keeps EventBus opt-in for custom runtimes", () => { + const omitted = createAngular(); + const included = createAngular({ + modules: [eventBusModule], + }); + + expect(omitted.injector(["ng"]).has("$eventBus")).toBeFalse(); + expect( + included.injector(["ng"]).get("$eventBus") instanceof EventBus, + ).toBeTrue(); + + omitted._composition.destroy(); + included._composition.destroy(); + }); + + it("applies custom-runtime EventBus configuration", async () => { + const deliveryPolicy = jasmine + .createSpy("deliveryPolicy") + .and.returnValue({ type: "drop" }); + const angular = createAngular({ + modules: [eventBusModule], + }); + + angular.module("configuredCustomEventBus", []).config({ + $eventBus: { deliveryPolicy }, + }); + + const eventBus = angular + .injector(["ng", "configuredCustomEventBus"]) + .get("$eventBus"); + const listener = jasmine.createSpy("listener"); + + eventBus.subscribe("custom:configured", listener); + eventBus.publish("custom:configured", "payload"); + await wait(); + + expect(deliveryPolicy).toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + + angular._composition.destroy(); + }); + + it("does not dispose an externally owned EventBus", () => { + const state = createEventBusRuntimeState(); + const external = new EventBus(() => undefined); + const policy = jasmine.createSpy("deliveryPolicy").and.returnValue("drop"); + + expect(createEventBusService(state, () => undefined, external)).toBe( + external, + ); + expect(createEventBusService(state, () => undefined)).toBe(external); + + applyEventBusConfiguration(state, { deliveryPolicy: policy }); + destroyEventBusRuntimeState(state); + destroyEventBusRuntimeState(state); + + expect(external.isDisposed()).toBeFalse(); + expect(() => + applyEventBusConfiguration(state, { deliveryPolicy: policy }), + ).toThrowError("EventBus runtime has already been disposed."); + expect(() => createEventBusService(state, () => undefined)).toThrowError( + "EventBus runtime has already been disposed.", + ); + + external.dispose(); + }); +}); + +describe("EventBus", function () { + let eventBus; + + beforeEach(function () { + eventBus = new EventBus(() => undefined); + }); + + afterEach(function () { + eventBus.dispose(); + eventBus.dispose(); + }); + + it("should create a EventBus instance", function () { + expect(eventBus).not.toBeNull(); + expect(eventBus instanceof EventBus).toBe(true); + }); + + it("should provide injecables", function () { + expect(eventBus._exceptionHandler).not.toBeNull(); + }); + + it("should dispose of the EventBus instance", function () { + expect(eventBus.isDisposed()).toBe(false); + eventBus.dispose(); + expect(eventBus.isDisposed()).toBe(true); + }); + + it("should subscribe and unsubscribe correctly", function () { + function foo1() {} + function bar1() {} + function foo2() {} + function bar2() {} + + expect(eventBus.getCount("foo")).toBe(0); + expect(eventBus.getCount("bar")).toBe(0); + + eventBus.subscribe("foo", foo1); + expect(eventBus.getCount("foo")).toBe(1); + expect(eventBus.getCount("bar")).toBe(0); + + eventBus.subscribe("bar", bar1); + expect(eventBus.getCount("foo")).toBe(1); + expect(eventBus.getCount("bar")).toBe(1); + + eventBus.subscribe("foo", foo2); + expect(eventBus.getCount("foo")).toBe(2); + expect(eventBus.getCount("bar")).toBe(1); + + eventBus.subscribe("bar", bar2); + expect(eventBus.getCount("foo")).toBe(2); + expect(eventBus.getCount("bar")).toBe(2); + + expect(eventBus.unsubscribe("foo", foo1)).toBe(true); + expect(eventBus.getCount("foo")).toBe(1); + expect(eventBus.getCount("bar")).toBe(2); + + expect(eventBus.unsubscribe("foo", foo2)).toBe(true); + expect(eventBus.getCount("foo")).toBe(0); + expect(eventBus.getCount("bar")).toBe(2); + + expect(eventBus.unsubscribe("bar", bar1)).toBe(true); + expect(eventBus.getCount("foo")).toBe(0); + expect(eventBus.getCount("bar")).toBe(1); + + expect(eventBus.unsubscribe("bar", bar2)).toBe(true); + expect(eventBus.getCount("foo")).toBe(0); + expect(eventBus.getCount("bar")).toBe(0); + + expect(eventBus.unsubscribe("baz", foo1)).toBe(false); + expect( + eventBus.unsubscribe("foo", () => { + /* empty */ + }), + ).toBe(false); + }); + + it("should subscribe and unsubscribe with context correctly", function () { + function foo() {} + function bar() {} + + const contextA = {}; + + const contextB = {}; + + expect(eventBus.getCount("X")).toBe(0); + + eventBus.subscribe("X", foo, contextA); + expect(eventBus.getCount("X")).toBe(1); + + eventBus.subscribe("X", bar); + expect(eventBus.getCount("X")).toBe(2); + + eventBus.subscribe("X", bar, contextB); + expect(eventBus.getCount("X")).toBe(3); + + expect(eventBus.unsubscribe("X", foo, contextB)).toBe(false); + + expect(eventBus.unsubscribe("X", foo, contextA)).toBe(true); + expect(eventBus.getCount("X")).toBe(2); + + expect(eventBus.unsubscribe("X", bar)).toBe(true); + expect(eventBus.getCount("X")).toBe(1); + + expect(eventBus.unsubscribe("X", bar, contextB)).toBe(true); + expect(eventBus.getCount("X")).toBe(0); + }); + + it("should auto-unsubscribe scope context listeners on destroy", async function () { + const scope = createScope({ count: 0 }); + + eventBus.subscribe( + "scopeTopic", + function () { + this.count++; + }, + scope, + ); + + expect(eventBus.getCount("scopeTopic")).toBe(1); + + scope.$destroy(); + + expect(eventBus.getCount("scopeTopic")).toBe(0); + expect(eventBus.publish("scopeTopic")).toBe(false); + + await wait(); + expect(scope.count).toBe(0); + }); + + it("should remove scope destroy hook when cleanup is called manually", function () { + const scope = createScope(); + const listener = jasmine.createSpy("listener"); + + const cleanup = eventBus.subscribe("scopeTopic", listener, scope); + + expect(eventBus.getCount("scopeTopic")).toBe(1); + expect(scope.$handler._listeners.get("$destroy").length).toBe(2); + + expect(cleanup()).toBe(true); + + expect(eventBus.getCount("scopeTopic")).toBe(0); + expect(scope.$handler._listeners.get("$destroy").length).toBe(1); + + scope.$destroy(); + + expect(cleanup()).toBe(false); + expect(eventBus.getCount("scopeTopic")).toBe(0); + }); + + it("should keep plain context lifecycle behavior unchanged", function () { + const listener = jasmine.createSpy("listener"); + const context = { + $on: jasmine.createSpy("$on"), + }; + + const cleanup = eventBus.subscribe("plainTopic", listener, context); + + expect(context.$on).not.toHaveBeenCalled(); + expect(eventBus.getCount("plainTopic")).toBe(1); + expect(cleanup()).toBe(true); + expect(cleanup()).toBe(false); + }); + + it("should return an idempotent unsubscribe function for a listener", function () { + const listener = jasmine.createSpy("listener"); + + const unsubscribe = eventBus.subscribe("someTopic", listener); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(unsubscribe()).toBe(true); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(unsubscribe()).toBe(false); + }); + + it("should clear listeners and make the instance reusable on reset", function () { + const listener = jasmine.createSpy("listener"); + + eventBus.subscribe("someTopic", listener); + expect(eventBus.getCount("someTopic")).toBe(1); + + eventBus.dispose(); + expect(eventBus.isDisposed()).toBe(true); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(eventBus.publish("someTopic")).toBe(false); + + eventBus.reset(); + expect(eventBus.isDisposed()).toBe(false); + expect(eventBus.getCount("someTopic")).toBe(0); + + eventBus.subscribe("someTopic", listener); + expect(eventBus.getCount("someTopic")).toBe(1); + }); + + it("should reject subscribe and unsubscribe work while disposed", function () { + const listener = jasmine.createSpy("listener"); + + eventBus.dispose(); + + const unsubscribe = eventBus.subscribe("someTopic", listener); + const unsubscribeOnce = eventBus.subscribeOnce("someTopic", listener); + + expect(eventBus.getCount("someTopic")).toBe(0); + expect(unsubscribe()).toBe(false); + expect(unsubscribeOnce()).toBe(false); + expect(eventBus.unsubscribe("someTopic", listener)).toBe(false); + expect(eventBus.publish("someTopic")).toBe(false); + }); + + it("should keep diagnostics limited to active listener counts", function () { + const scope = createScope(); + const scopedListener = jasmine.createSpy("scopedListener"); + const plainListener = jasmine.createSpy("plainListener"); + + eventBus.subscribe("diagnosticsTopic", scopedListener, scope); + const cleanupPlain = eventBus.subscribe("diagnosticsTopic", plainListener); + + expect(eventBus.getCount("diagnosticsTopic")).toBe(2); + + scope.$destroy(); + + expect(eventBus.getCount("diagnosticsTopic")).toBe(1); + + cleanupPlain(); + + expect(eventBus.getCount("diagnosticsTopic")).toBe(0); + expect(eventBus.getTopics).toBeUndefined(); + expect(eventBus.getScopedCount).toBeUndefined(); + expect(eventBus.getLeakReport).toBeUndefined(); + expect(eventBus.diagnostics).toBeUndefined(); + }); + + it("should subscribe once correctly", async function () { + let called; + + let context; + + called = false; + eventBus.subscribeOnce("someTopic", () => { + called = true; + }); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(1); + expect(called).toBe(false); + + eventBus.publish("someTopic"); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(called).toBe(true); + + context = { called: false }; + eventBus.subscribeOnce( + "someTopic", + function () { + this.called = true; + }, + context, + ); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(1); + expect(context.called).toBe(false); + + eventBus.publish("someTopic"); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(context.called).toBe(true); + + context = { called: false, value: 0 }; + eventBus.subscribeOnce( + "someTopic", + function (value) { + this.called = true; + this.value = value; + }, + context, + ); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(1); + expect(context.called).toBe(false); + expect(context.value).toBe(0); + + eventBus.publish("someTopic", 17); + await wait(); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(context.called).toBe(true); + expect(context.value).toBe(17); + }); + + it("should auto-clean scope subscribe once listeners on destroy before delivery", async function () { + const scope = createScope({ called: false }); + + eventBus.subscribeOnce( + "someTopic", + function () { + this.called = true; + }, + scope, + ); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(scope.$handler._listeners.get("$destroy").length).toBe(2); + + eventBus.publish("someTopic"); + scope.$destroy(); + + await wait(); + + expect(scope.called).toBe(false); + expect(eventBus.getCount("someTopic")).toBe(0); + }); + + it("should remove scope destroy hook after subscribe once delivery", async function () { + const scope = createScope({ callCount: 0, value: "" }); + + const cleanup = eventBus.subscribeOnce( + "someTopic", + function (value) { + this.callCount++; + this.value = value; + }, + scope, + ); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(scope.$handler._listeners.get("$destroy").length).toBe(2); + + eventBus.publish("someTopic", "first"); + eventBus.publish("someTopic", "second"); + + await wait(); + + expect(scope.callCount).toBe(1); + expect(scope.value).toBe("first"); + expect(eventBus.getCount("someTopic")).toBe(0); + expect(scope.$handler._listeners.get("$destroy").length).toBe(1); + expect(cleanup()).toBe(false); + }); + + it("should async subscribe once correctly", function (done) { + let callCount = 0; + + eventBus.subscribeOnce("someTopic", () => { + callCount++; + }); + expect(eventBus.getCount("someTopic")).toBe(1); + eventBus.publish("someTopic"); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(0); + expect(callCount).toBe(1); + done(); + }, 0); + }); + + it("should async subscribe once with context correctly", function (done) { + const context = { callCount: 0 }; + + eventBus.subscribeOnce( + "someTopic", + function () { + this.callCount++; + }, + context, + ); + expect(eventBus.getCount("someTopic")).toBe(1); + + eventBus.publish("someTopic"); + eventBus.publish("someTopic"); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(0); + expect(context.callCount).toBe(1); + done(); + }, 0); + }); + + it("should async subscribe once with context and value correctly", function (done) { + const context = { callCount: 0, value: 0 }; + + eventBus.subscribeOnce( + "someTopic", + function (value) { + this.callCount++; + this.value = value; + }, + context, + ); + expect(eventBus.getCount("someTopic")).toBe(1); + + eventBus.publish("someTopic", 17); + eventBus.publish("someTopic", 42); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(0); + expect(context.callCount).toBe(1); + expect(context.value).toBe(17); + done(); + }, 0); + }); + + it("should subscribe once with bound function correctly", async function () { + const context = { called: false, value: 0 }; + + function subscriber(value) { + this.called = true; + this.value = value; + } + + eventBus.subscribeOnce("someTopic", subscriber.bind(context)); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(context.called).toBe(false); + expect(context.value).toBe(0); + + eventBus.publish("someTopic", 17); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(0); + expect(context.called).toBe(true); + expect(context.value).toBe(17); + }); + + it("should subscribe once with partial function correctly", async function () { + let called = false; + + let value = 0; + + function subscriber(hasBeenCalled, newValue) { + called = hasBeenCalled; + value = newValue; + } + + eventBus.subscribeOnce("someTopic", subscriber.bind(null, true)); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(called).toBe(false); + expect(value).toBe(0); + + eventBus.publish("someTopic", 17); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(0); + expect(called).toBe(true); + expect(value).toBe(17); + }); + + it("should handle self resubscribe correctly", async function () { + let value = null; + + function resubscribe(iteration, newValue) { + eventBus.subscribeOnce( + "someTopic", + resubscribe.bind(null, iteration + 1), + ); + value = `${newValue}:${iteration}`; + } + + eventBus.subscribeOnce("someTopic", resubscribe.bind(null, 0)); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBeNull(); + + eventBus.publish("someTopic", "foo"); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("foo:0"); + + eventBus.publish("someTopic", "bar"); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("bar:1"); + + eventBus.publish("someTopic", "baz"); + await wait(); + + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("baz:2"); + }); + + it("should handle async self resubscribe correctly", function (done) { + let value = null; + + function resubscribe(iteration, newValue) { + eventBus.subscribeOnce( + "someTopic", + resubscribe.bind(null, iteration + 1), + ); + value = `${newValue}:${iteration}`; + } + + eventBus.subscribeOnce("someTopic", resubscribe.bind(null, 0)); + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBeNull(); + + eventBus.publish("someTopic", "foo"); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("foo:0"); + + eventBus.publish("someTopic", "bar"); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("bar:1"); + + eventBus.publish("someTopic", "baz"); + + setTimeout(() => { + expect(eventBus.getCount("someTopic")).toBe(1); + expect(value).toBe("baz:2"); + done(); + }, 0); + }, 0); + }, 0); + }); + + describe("publish", () => { + let context, fooCalled, barCalled, SOME_TOPIC; + + beforeEach(function () { + context = {}; + fooCalled = false; + barCalled = false; + SOME_TOPIC = "someTopic"; + }); + + function foo(record) { + fooCalled = true; + expect(record.x).toBe("x"); + expect(record.y).toBe("y"); + } + + function bar(record) { + barCalled = true; + expect(this).toBe(context); + expect(record.x).toBe("x"); + expect(record.y).toBe("y"); + } + + it("should call subscribed functions on publish", async function () { + eventBus.subscribe(SOME_TOPIC, foo); + eventBus.subscribe(SOME_TOPIC, bar, context); + + expect(eventBus.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); + await wait(); + expect(fooCalled).toBe(true, "foo() must have been called"); + expect(barCalled).toBe(true, "bar() must have been called"); + }); + + it("should skip scope listeners destroyed after publish is queued", async function () { + const scope = createScope({ calls: [] }); + const calls = []; + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + calls.push("scope"); + }, + scope, + ); + eventBus.subscribe(SOME_TOPIC, () => { + calls.push("plain"); + }); + + expect(eventBus.publish(SOME_TOPIC, "queued")).toBe(true); + + scope.$destroy(); + + await wait(); + + expect(scope.calls).toEqual([]); + expect(calls).toEqual(["plain"]); + expect(eventBus.getCount(SOME_TOPIC)).toBe(1); + }); + + it("should defer scope listeners while scope is retention-paused", async function () { + const scope = createScope({ calls: [] }); + const calls = []; + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + calls.push(value); + }, + scope, + ); + + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + expect(eventBus.publish(SOME_TOPIC, "first")).toBe(true); + expect(eventBus.publish(SOME_TOPIC, "second")).toBe(true); + + await wait(); + + expect(scope.calls).toEqual([]); + expect(calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + + await wait(); + + expect(scope.calls).toEqual(["first", "second"]); + expect(calls).toEqual(["first", "second"]); + }); + + it("should reuse scope retention state for multiple listeners on the same scope", async function () { + const scope = createScope({ calls: [] }); + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(`first:${value}`); + }, + scope, + ); + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(`second:${value}`); + }, + scope, + ); + + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + expect(eventBus.publish(SOME_TOPIC, "queued")).toBe(true); + + await wait(); + + expect(scope.calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + + await wait(); + + expect(scope.calls).toEqual(["first:queued", "second:queued"]); + expect(scope.$handler._listeners.get("$viewRetentionPause").length).toBe( + 1, + ); + expect(scope.$handler._listeners.get("$viewRetentionResume").length).toBe( + 1, + ); + }); + + it("should leave paused deliveries queued if scope pauses again before the drain", async function () { + const scope = createScope({ calls: [] }); + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + }, + scope, + ); + + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + expect(eventBus.publish(SOME_TOPIC, "queued")).toBe(true); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + + await wait(); + + expect(scope.calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + + await wait(); + + expect(scope.calls).toEqual(["queued"]); + }); + + it("should ignore unrelated retention resume modes and unpaused resumes", async function () { + const scope = createScope({ calls: [] }); + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + }, + scope, + ); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + expect(eventBus.publish(SOME_TOPIC, "queued")).toBe(true); + + scope.$broadcast("$viewRetentionResume", { _pause: "background" }); + + await wait(); + + expect(scope.calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume", { _pause: "schedulers" }); + + await wait(); + + expect(scope.calls).toEqual(["queued"]); + }); + + it("ignores retention pause for unrelated modes", async function () { + const scope = createScope({ calls: [] }); + const calls = []; + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + calls.push(value); + }, + scope, + ); + + eventBus.publish(SOME_TOPIC, "immediate"); + await wait(); + expect(scope.calls).toEqual(["immediate"]); + expect(calls).toEqual(["immediate"]); + + eventBus.publish(SOME_TOPIC, "first"); + scope.$broadcast("$viewRetentionPause", { _pause: "background" }); + eventBus.publish(SOME_TOPIC, "background"); + await wait(); + + expect(scope.calls).toEqual(["immediate", "first", "background"]); + expect(calls).toEqual(["immediate", "first", "background"]); + }); + + it("should drop pending paused scope listeners when scope is destroyed", async function () { + const scope = createScope({ calls: [] }); + const calls = []; + + eventBus.subscribe( + SOME_TOPIC, + function (value) { + this.calls.push(value); + calls.push(value); + }, + scope, + ); + + scope.$broadcast("$viewRetentionPause", { _pause: "schedulers" }); + expect(eventBus.publish(SOME_TOPIC, "first")).toBe(true); + + scope.$destroy(); + await wait(); + + expect(scope.calls).toEqual([]); + expect(calls).toEqual([]); + expect(eventBus.getCount(SOME_TOPIC)).toBe(0); + }); + + it("should preserve plain listener snapshot delivery after unsubscribe", async function () { + const listener = jasmine.createSpy("listener"); + + const unsubscribe = eventBus.subscribe(SOME_TOPIC, listener); + + expect(eventBus.publish(SOME_TOPIC, "queued")).toBe(true); + expect(unsubscribe()).toBe(true); + expect(eventBus.getCount(SOME_TOPIC)).toBe(0); + + await wait(); + + expect(listener).toHaveBeenCalledOnceWith("queued"); + }); + + it("should deliver active listeners in subscription order", async function () { + const calls = []; + + eventBus.subscribe(SOME_TOPIC, () => calls.push("first")); + eventBus.subscribe(SOME_TOPIC, () => calls.push("second")); + eventBus.subscribe(SOME_TOPIC, () => calls.push("third")); + + expect(eventBus.publish(SOME_TOPIC)).toBe(true); + + await wait(); + + expect(calls).toEqual(["first", "second", "third"]); + }); + + it("should evaluate delivery policy with normalized listener context", async function () { + const policy = jasmine + .createSpy("deliveryPolicy") + .and.returnValues(Promise.resolve("deliver"), Promise.resolve("drop")); + const calls = []; + + eventBus.setDeliveryPolicy(policy); + eventBus.subscribe(SOME_TOPIC, () => calls.push("first")); + eventBus.subscribe(SOME_TOPIC, () => calls.push("second")); + + expect(eventBus.publish(SOME_TOPIC, "payload")).toBe(true); + + await wait(); + + expect(calls).toEqual(["first"]); + expect(policy).toHaveBeenCalledWith({ + operation: "event.delivery", + topic: SOME_TOPIC, + args: ["payload"], + listenerIndex: 0, + scopeOwned: false, + targetAlive: true, + }); + expect(policy).toHaveBeenCalledWith({ + operation: "event.delivery", + topic: SOME_TOPIC, + args: ["payload"], + listenerIndex: 1, + scopeOwned: false, + targetAlive: true, + }); + }); + + it("should report unsupported delivery policy decisions", async function () { + const receivedErrors = []; + const listener = jasmine.createSpy("listener"); + + eventBus = new EventBus((err) => { + receivedErrors.push(err); + }); + eventBus.setDeliveryPolicy(() => "retry" as never); + eventBus.subscribe(SOME_TOPIC, listener); + + expect(eventBus.publish(SOME_TOPIC)).toBe(true); + + await wait(); + + expect(listener).not.toHaveBeenCalled(); + expect(receivedErrors.length).toBe(1); + expect(receivedErrors[0].message).toBe( + "Unsupported event delivery policy decision: retry", + ); + }); + + it("should route delivery policy errors through the exception handler", async function () { + const policyError = new Error("policy failed"); + const receivedErrors = []; + const listener = jasmine.createSpy("listener"); + + eventBus = new EventBus((err) => { + receivedErrors.push(err); + }); + eventBus.setDeliveryPolicy(() => { + throw policyError; + }); + eventBus.subscribe(SOME_TOPIC, listener); + + expect(eventBus.publish(SOME_TOPIC)).toBe(true); + + await wait(); + + expect(listener).not.toHaveBeenCalled(); + expect(receivedErrors).toEqual([policyError]); + }); + + it("should ignore stale paused-scope queue entries", async function () { + const scope = createScope(); + const pending = [ + { + _topic: SOME_TOPIC, + _args: [], + _listenerIndex: 0, + _entry: { + _scopeLifecycleContext: true, + _context: scope, + }, + }, + ]; + const state = { + _paused: true, + _pending: pending, + _flushing: true, + }; + + expect(() => { + eventBus._queuePausedScopeDelivery(SOME_TOPIC, [], 0, { + _scopeLifecycleContext: false, + }); + eventBus._queuePausedScopeDelivery(SOME_TOPIC, [], 0, { + _scopeLifecycleContext: true, + _context: scope, + }); + }).not.toThrow(); + + await eventBus._drainScopeDeliveryQueue(state); + + expect(state._flushing).toBeFalse(); + expect(state._pending).toBe(pending); + }); + + it("should not call unsubscribed functions on publish", async function () { + eventBus.subscribe(SOME_TOPIC, foo); + eventBus.subscribe(SOME_TOPIC, bar, context); + + eventBus.publish(SOME_TOPIC, { x: "x", y: "y" }); + await wait(); + expect(fooCalled).toBe(true, "foo() must have been called"); + expect(barCalled).toBe(true, "bar() must have been called"); + fooCalled = false; + barCalled = false; + expect(eventBus.unsubscribe(SOME_TOPIC, foo)).toBe(true); + expect(eventBus.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); + + await wait(); + expect(fooCalled).toBe(false, "foo() must not have been called"); + expect(barCalled).toBe(true, "bar() must have been called"); + }); + + it("should only call functions subscribed to the correct topic", async function () { + eventBus.subscribe(SOME_TOPIC, bar, context); + eventBus.subscribe("differentTopic", foo); + + eventBus.publish(SOME_TOPIC, { x: "x", y: "y" }); + fooCalled = false; + barCalled = false; + + await wait(); + expect(eventBus.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); + expect(fooCalled).toBe(false, "foo() must not have been called"); + expect(barCalled).toBe(true, "bar() must have been called"); + }); + + it("should trigger functions if not arguments are provided", async function () { + let called = false; + + eventBus.subscribe(SOME_TOPIC, () => { + called = true; + 0; + }); + + eventBus.publish(SOME_TOPIC); + await wait(); + + expect(eventBus.publish(SOME_TOPIC)).toBe(true); + expect(called).toBeTrue(); + }); + + it("should delegate to exception handler if an error is thrown", async function () { + let thrown = false; + + const thrownError = new Error(); + + let receivedErr; + + eventBus = new EventBus((err) => { + thrown = true; + receivedErr = err; + }); + + eventBus.subscribe(SOME_TOPIC, () => { + throw thrownError; + }); + + eventBus.publish(SOME_TOPIC); + await wait(); + + expect(thrown).toBe(true); + expect(receivedErr).toBe(thrownError); + }); + + it("should keep delivering active listeners after a listener throws", async function () { + const thrownError = new Error("boom"); + const calls = []; + let receivedErr; + + eventBus = new EventBus((err) => { + receivedErr = err; + }); + + eventBus.subscribe(SOME_TOPIC, () => { + calls.push("first"); + }); + eventBus.subscribe(SOME_TOPIC, () => { + calls.push("throwing"); + throw thrownError; + }); + eventBus.subscribe(SOME_TOPIC, () => { + calls.push("third"); + }); + + expect(eventBus.publish(SOME_TOPIC)).toBe(true); + + await wait(); + + expect(receivedErr).toBe(thrownError); + expect(calls).toEqual(["first", "throwing", "third"]); + }); + }); +}); diff --git a/src/services/pubsub/pubsub.test.ts b/src/services/event-bus/event-bus.test.ts similarity index 79% rename from src/services/pubsub/pubsub.test.ts rename to src/services/event-bus/event-bus.test.ts index ed1a39422..b8336b855 100644 --- a/src/services/pubsub/pubsub.test.ts +++ b/src/services/event-bus/event-bus.test.ts @@ -1,7 +1,7 @@ import { test } from "@playwright/test"; import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; -const TEST_URL = "src/services/pubsub/pubsub.html"; +const TEST_URL = "src/services/event-bus/event-bus.html"; test("unit tests contain no errors", async ({ page }) => { await expectNoJasmineFailures(page, TEST_URL); diff --git a/src/services/event-bus/event-bus.ts b/src/services/event-bus/event-bus.ts new file mode 100644 index 000000000..26a1cced1 --- /dev/null +++ b/src/services/event-bus/event-bus.ts @@ -0,0 +1,615 @@ +import { + isProxy, + nullObject, + shouldHandleViewRetentionPause, +} from "../../shared/utils.ts"; +import type { + Policy, + PolicyContext, + PolicyDecision, +} from "../../core/policy/policy.ts"; + +type EventDeliveryDecisionType = "deliver" | "drop"; + +export interface EventDeliveryPolicyContext extends PolicyContext { + operation: "event.delivery"; + topic: string; + args: unknown[]; + listenerIndex: number; + scopeOwned: boolean; + targetAlive: boolean; +} + +type EventDeliveryPolicyDecision = PolicyDecision; + +export type EventDeliveryPolicy = Policy< + EventDeliveryPolicyContext, + PolicyDecision<"deliver" | "drop"> +>; + +export interface EventBusConfig { + deliveryPolicy?: EventDeliveryPolicy; +} + +/** + * Callback signature used by {@link EventBus} subscriptions. + * + * The generic parameter describes the callback `this` binding when a + * subscription is registered with a context argument. + */ +export type EventBusListener = ( + this: TContext, + ...args: unknown[] +) => unknown; + +type StoredEventBusListener = (this: unknown, ...args: unknown[]) => unknown; + +interface ListenerEntry { + _fn: StoredEventBusListener; + _context: unknown; + _active: boolean; + _scopeLifecycleContext: boolean; +} + +interface PendingScopeDelivery { + _topic: string; + _args: unknown[]; + _listenerIndex: number; + _entry: ListenerEntry; +} + +interface ScopeEventBusRetentionState { + _paused: boolean; + _pending: PendingScopeDelivery[]; + _flushing: boolean; + _deregisterPause: () => void; + _deregisterResume: () => void; + _deregisterDestroy: () => void; +} + +type ScopeLifecycleContext = ng.Scope & { + $on(name: "$destroy", listener: () => unknown): () => void; + $handler?: { + _destroyed?: boolean; + }; +}; + +function isScopeLifecycleContext( + value: unknown, +): value is ScopeLifecycleContext { + return isProxy(value) && typeof value.$on === "function"; +} + +function isDestroyedScopeLifecycleContext(value: unknown): boolean { + return isScopeLifecycleContext(value) && value.$handler._destroyed; +} + +/** + * Application-wide asynchronous publish/subscribe utility. + * + * `EventBus` powers `$eventBus` for cross-boundary domain events, browser + * callbacks, worker messages, realtime messages, and non-Angular integrations. + * It is intentionally not a state store and should not replace scope events for + * parent/child scope-tree communication. + */ +export class EventBus { + /** @internal */ + private _topics: Partial>; + /** @internal */ + private _disposed: boolean; + /** @internal */ + private readonly _exceptionHandler: ng.ExceptionHandlerService; + /** @internal */ + private _deliveryPolicy: EventDeliveryPolicy; + /** @internal */ + private _scopeRetentionStates = new WeakMap< + ScopeLifecycleContext, + ScopeEventBusRetentionState + >(); + + /** + * Create a publish/subscribe event bus. + * + * Applications usually receive the singleton instance by injecting + * `$eventBus` instead of constructing this class directly. + * + * @param $exceptionHandler - Handler invoked when a subscriber throws. + */ + constructor( + $exceptionHandler: ng.ExceptionHandlerService, + deliveryPolicy: EventDeliveryPolicy = defaultEventDeliveryPolicy, + ) { + this._topics = nullObject(); + this._disposed = false; + this._exceptionHandler = $exceptionHandler; + this._deliveryPolicy = deliveryPolicy; + } + + /** + * Reset the bus to its initial state without disposing it. + * + * All topics and listeners are removed, and the instance can be reused. + */ + reset(): void { + this._topics = nullObject(); + this._disposed = false; + this.setDeliveryPolicy(); + } + + /** + * Replace the runtime delivery policy used by future publications. + * + * The default policy delivers every active listener. Configured policies can + * drop deliveries for specific topics, scopes, or application metadata. + */ + setDeliveryPolicy(policy?: EventDeliveryPolicy): void { + this._deliveryPolicy = policy ?? defaultEventDeliveryPolicy; + } + + /** + * Checks if instance has been disposed. + * @returns True if disposed. + */ + isDisposed(): boolean { + return this._disposed; + } + + /** + * Dispose the instance, removing all topics and listeners. + */ + dispose(): void { + if (this._disposed) return; + this._disposed = true; + this._topics = nullObject(); + } + + /** + * Subscribe a function to a topic. + * + * The returned function removes only this listener registration. + * When `context` is provided, it becomes the listener `this` binding. When + * `context` is an AngularTS scope proxy, the scope also owns the listener + * lifecycle: destroying the scope removes the listener and prevents queued + * delivery from reaching the destroyed scope. + * + * @param topic - The topic to subscribe to. + * @param fn - The callback function to invoke when published. + * @returns A function that unsubscribes this listener. + */ + subscribe(topic: string, fn: EventBusListener): () => boolean; + subscribe( + topic: string, + fn: EventBusListener>, + context: TContext, + ): () => boolean; + subscribe( + topic: string, + fn: EventBusListener>, + context?: TContext, + ): () => boolean { + if (this._disposed) return () => false; + let listeners = this._topics[topic]; + + if (!listeners) this._topics[topic] = listeners = []; + + const scopeLifecycleContext = isScopeLifecycleContext(context); + + const entry: ListenerEntry = { + _fn: fn as StoredEventBusListener, + _context: context, + _active: true, + _scopeLifecycleContext: scopeLifecycleContext, + }; + + listeners.push(entry); + + const unsubscribe = () => this._unsubscribe(topic, fn, context); + + if (!scopeLifecycleContext) { + return unsubscribe; + } + + this._getScopeRetentionState(context as ScopeLifecycleContext); + + let removeDestroyListener: (() => void) | undefined = context.$on( + "$destroy", + () => { + cleanup(); + }, + ); + + const cleanup = () => { + const didUnsubscribe = unsubscribe(); + + if (removeDestroyListener) { + const remove = removeDestroyListener; + + removeDestroyListener = undefined; + remove(); + } + + return didUnsubscribe; + }; + + return cleanup; + } + + /** + * Subscribe a function to a topic only once. + * + * Listener is removed before the first invocation. + * When `context` is provided, it becomes the listener `this` binding. When + * `context` is an AngularTS scope proxy, scope destruction before first + * delivery removes the one-time listener. + * + * @param topic - The topic to subscribe to. + * @param fn - The callback function. + * @returns A function that unsubscribes this listener. + */ + subscribeOnce(topic: string, fn: EventBusListener): () => boolean; + subscribeOnce( + topic: string, + fn: EventBusListener>, + context: TContext, + ): () => boolean; + subscribeOnce( + topic: string, + fn: EventBusListener>, + context?: TContext, + ): () => boolean { + if (this._disposed) return () => false; + + let called = false; + + const wrapper = (...args: unknown[]) => { + if (called) return; + called = true; + + unsub(); // unsubscribe before running + Reflect.apply(fn, context, args); + }; + + const unsub = + context === undefined + ? this.subscribe(topic, wrapper) + : this.subscribe(topic, wrapper, context); + + return unsub; + } + + /** + * Unsubscribe a specific function from a topic. + * Matches by function reference and optional context. + * @param topic - The topic to unsubscribe from. + * @param fn - The listener function. + * @returns True if the listener was found and removed. + */ + unsubscribe(topic: string, fn: EventBusListener): boolean; + unsubscribe( + topic: string, + fn: EventBusListener>, + context: TContext, + ): boolean; + unsubscribe( + topic: string, + fn: EventBusListener>, + context?: TContext, + ): boolean { + return this._unsubscribe(topic, fn, context); + } + + private _unsubscribe( + topic: string, + fn: EventBusListener, + context?: TContext, + ): boolean { + if (this._disposed) return false; + + const listeners = this._topics[topic]; + + if (!listeners || listeners.length === 0) return false; + + for (let i = 0; i < listeners.length; i++) { + const l = listeners[i]; + + if (l._fn === fn && l._context === context) { + l._active = false; + listeners.splice(i, 1); + + return true; + } + } + + return false; + } + + /** + * Get the number of subscribers for a topic. + * + * This is the public diagnostic surface for `$eventBus`. It reports active + * registered listeners only; topic listings, leak reports, and reactive + * diagnostics are intentionally not exposed. + * + * @param topic - Topic name to inspect. + * @returns The number of currently registered listeners. + */ + getCount(topic: string): number { + const listeners = this._topics[topic]; + + return listeners ? listeners.length : 0; + } + + /** + * Publish a value to a topic asynchronously. + * + * All listeners are invoked in the order they were added. + * Delivery is scheduled with `queueMicrotask`. Scope-owned listeners are + * skipped if their scope is destroyed before the queued delivery runs. + * + * @param topic - The topic to publish. + * @param args - Arguments to pass to listeners. + * @returns True if any listeners exist for this topic. + */ + publish(topic: string, ...args: unknown[]): boolean { + if (this._disposed) return false; + + const listeners = this._topics[topic]; + + if (!listeners || listeners.length === 0) return false; + + // snapshot to prevent modifications during publish from affecting this call + const snapshot = listeners.slice(); + + queueMicrotask(() => { + void this._deliverSnapshot(topic, args, snapshot); + }); + + return true; + } + + /** @internal */ + private async _deliverSnapshot( + topic: string, + args: unknown[], + snapshot: ListenerEntry[], + ): Promise { + for ( + let listenerIndex = 0; + listenerIndex < snapshot.length; + listenerIndex++ + ) { + const entry = snapshot[listenerIndex]; + const { _fn: fn, _context: context } = entry; + const targetAlive = + !entry._scopeLifecycleContext || + (entry._active && !isDestroyedScopeLifecycleContext(context)); + + if (!targetAlive) { + continue; + } + + if (entry._scopeLifecycleContext) { + const state = this._scopeRetentionStates.get( + context as ScopeLifecycleContext, + ); + + if (state?._paused) { + this._queuePausedScopeDelivery(topic, args, listenerIndex, entry); + + continue; + } + } + + let decision: EventDeliveryPolicyDecision | EventDeliveryDecisionType; + + try { + decision = await this._deliveryPolicy({ + operation: "event.delivery", + topic, + args, + listenerIndex, + scopeOwned: entry._scopeLifecycleContext, + targetAlive, + }); + } catch (err) { + this._exceptionHandler(err); + + continue; + } + + const decisionType: string = + typeof decision === "string" ? decision : decision.type; + + if (decisionType === "drop") { + continue; + } + + if (decisionType !== "deliver") { + this._exceptionHandler( + new Error( + `Unsupported event delivery policy decision: ${decisionType}`, + ), + ); + + continue; + } + + try { + fn.apply(context, args); + } catch (err) { + this._exceptionHandler(err); + } + } + } + + private _queuePausedScopeDelivery( + topic: string, + args: unknown[], + listenerIndex: number, + entry: ListenerEntry, + ): void { + if (!entry._scopeLifecycleContext) return; + + const scopeState = this._scopeRetentionStates.get( + entry._context as ScopeLifecycleContext, + ); + + if (!scopeState) return; + + scopeState._pending.push({ + _topic: topic, + _args: args, + _listenerIndex: listenerIndex, + _entry: entry, + }); + + this._flushScopeDeliveryQueue(scopeState); + } + + private _flushScopeDeliveryQueue(state: ScopeEventBusRetentionState): void { + if (state._flushing || state._paused || state._pending.length === 0) { + return; + } + + state._flushing = true; + + queueMicrotask(() => { + void this._drainScopeDeliveryQueue(state); + }); + } + + private async _drainScopeDeliveryQueue( + state: ScopeEventBusRetentionState, + ): Promise { + if (state._paused) { + state._flushing = false; + + return; + } + + const deliveries = state._pending; + state._pending = []; + state._flushing = false; + + for (let i = 0; i < deliveries.length; i++) { + const delivery = deliveries[i]; + await this._deliverSnapshot(delivery._topic, delivery._args, [ + delivery._entry, + ]); + } + } + + private _getScopeRetentionState( + scope: ScopeLifecycleContext, + ): ScopeEventBusRetentionState { + let state = this._scopeRetentionStates.get(scope); + + if (state) return state; + + let nextState!: ScopeEventBusRetentionState; + + const deregisterPause = scope.$on("$viewRetentionPause", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + nextState._paused = true; + }); + + const deregisterResume = scope.$on("$viewRetentionResume", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + if (!nextState._paused) return; + + nextState._paused = false; + this._flushScopeDeliveryQueue(nextState); + }); + + const deregisterDestroy = scope.$on("$destroy", () => { + nextState._pending = []; + nextState._deregisterPause(); + nextState._deregisterResume(); + nextState._deregisterDestroy(); + this._scopeRetentionStates.delete(scope); + }); + + state = nextState = { + _paused: false, + _pending: [], + _flushing: false, + _deregisterPause: deregisterPause, + _deregisterResume: deregisterResume, + _deregisterDestroy: deregisterDestroy, + }; + + this._scopeRetentionStates.set(scope, state); + + return state; + } +} + +const defaultEventDeliveryPolicy: EventDeliveryPolicy = () => "deliver"; + +/** @internal */ +export interface EventBusRuntimeState { + deliveryPolicy?: EventDeliveryPolicy; + service?: EventBus; + ownsService: boolean; + destroyed: boolean; +} + +/** @internal */ +export function createEventBusRuntimeState(): EventBusRuntimeState { + return { + ownsService: false, + destroyed: false, + }; +} + +/** @internal */ +export function applyEventBusConfiguration( + state: EventBusRuntimeState, + config: EventBusConfig, +): void { + if (state.destroyed) { + throw new Error("EventBus runtime has already been disposed."); + } + + state.deliveryPolicy = config.deliveryPolicy; + state.service?.setDeliveryPolicy(config.deliveryPolicy); +} + +/** @internal */ +export function createEventBusService( + state: EventBusRuntimeState, + exceptionHandler: ng.ExceptionHandlerService, + existing?: EventBus, +): EventBus { + if (state.destroyed) { + throw new Error("EventBus runtime has already been disposed."); + } + + if (state.service) return state.service; + + const service = existing ?? new EventBus(exceptionHandler); + + state.service = service; + state.ownsService = !existing; + service.setDeliveryPolicy(state.deliveryPolicy); + + return service; +} + +/** @internal */ +export function destroyEventBusRuntimeState(state: EventBusRuntimeState): void { + if (state.destroyed) return; + + state.destroyed = true; + + if (state.ownsService) state.service?.dispose(); + + state.service = undefined; + state.ownsService = false; +} diff --git a/src/services/exception/README.md b/src/services/exception/README.md new file mode 100644 index 000000000..a80c4fbe6 --- /dev/null +++ b/src/services/exception/README.md @@ -0,0 +1,48 @@ +# Exception Handler Service + +This module owns AngularTS fail-fast exception handling and the typed runtime +configuration used to replace the default handler. + +## Responsibilities + +- Provide the injectable `$exceptionHandler` service. +- Rethrow exceptions unchanged by default. +- Apply `NgModule.config({ $exceptionHandler: ... })` configuration. +- Keep one stable service function while allowing configuration to replace its + delegated handler. +- Supply early-composed framework systems, including the router, with the same + live handler contract. +- Release configured handler references during runtime teardown. + +## Public Surface + +- `ExceptionHandler` describes the fail-fast callback contract. +- `ExceptionHandlerConfig` configures the handler through `NgModule.config`. +- `ng.ExceptionHandlerService` is the injectable `$exceptionHandler` contract. + +The runtime state and its construction helpers are internal composition +details. Applications do not inject an exception-handler provider. + +## Configuration + +```ts +angular.module("app", []).config({ + $exceptionHandler: { + handler(error): never { + reportError(error); + throw error; + }, + }, +}); +``` + +Applications may decorate `$exceptionHandler` when they need injector-backed +dependencies. The typed config path is preferred for application-wide handler +policy that does not require injected services. + +## Runtime Model + +The runtime owns one mutable handler reference and one stable service wrapper. +Framework systems can retain the wrapper before module config blocks run; each +call delegates to the latest configured handler. Destruction clears the custom +handler and rejects subsequent calls. diff --git a/src/services/exception/exception.spec.ts b/src/services/exception/exception.spec.ts index 395feeef8..0662ae3a2 100644 --- a/src/services/exception/exception.spec.ts +++ b/src/services/exception/exception.spec.ts @@ -1,106 +1,99 @@ /// -import { ExceptionHandlerProvider } from "./exception.ts"; - -describe("ExceptionHandlerProvider", () => { - let provider: any; - - beforeEach(() => { - provider = new ExceptionHandlerProvider(); - }); - - it("rethrows Error instances by default", () => { - const handler = provider.$get(); - - const error = new Error("fail"); - - expect(() => handler(error)).toThrowError("fail"); - }); - - it("rethrows primitive values unchanged", () => { - const handler = provider.$get(); - +import { + applyExceptionHandlerConfiguration, + createExceptionHandlerRuntimeState, + createExceptionHandlerService, + destroyExceptionHandlerRuntimeState, +} from "./exception.ts"; + +describe("$exceptionHandler runtime", () => { + it("rethrows values unchanged by default", () => { + const state = createExceptionHandlerRuntimeState(); + const handler = createExceptionHandlerService(state); + const object = { id: 1 }; + + expect(() => handler(new Error("fail"))).toThrowError("fail"); expect(() => handler("primitive")).toThrow("primitive"); expect(() => handler(42)).toThrow(42); - }); - - it("rethrows the same object instance", () => { - const value = { foo: "bar" }; - - const handler = provider.$get(); try { - handler(value); + handler(object); fail("Expected an exception to be thrown"); - } catch (e) { - expect(e).toBe(value); + } catch (error) { + expect(error).toBe(object); } - }); - - it("rethrows null and undefined unchanged", () => { - const handler = provider.$get(); try { handler(null); fail("Expected null to be thrown"); - } catch (e) { - expect(e).toBe(null); + } catch (error) { + expect(error).toBe(null); } try { handler(undefined); fail("Expected undefined to be thrown"); - } catch (e) { - expect(e).toBe(undefined); + } catch (error) { + expect(error).toBe(undefined); } }); - it("delegates to the configured handler with the same value", () => { - const value = { id: 1 }; - + it("uses the latest configured handler through one stable service", () => { + const state = createExceptionHandlerRuntimeState(); + const service = createExceptionHandlerService(state); + const first = new Error("first"); + const second = new Error("second"); let received: unknown; - provider.handler = (exception: any) => { - received = exception; - throw exception; - }; + applyExceptionHandlerConfiguration(state, { + handler(exception): never { + received = exception; + throw first; + }, + }); - const handler = provider.$get(); + expect(() => service("reported")).toThrow(first); + expect(received).toBe("reported"); - try { - handler(value); - fail("Expected an exception to be thrown"); - } catch (e) { - expect(e).toBe(value); - } + applyExceptionHandlerConfiguration(state, { + handler(): never { + throw second; + }, + }); - expect(received).toBe(value); + expect(createExceptionHandlerService(state)).toBe(service); + expect(() => service("ignored")).toThrow(second); }); - it("uses the latest handler even if reconfigured after $get()", () => { - provider.handler = () => { - throw "first"; - }; + it("keeps the current handler when configuration omits it", () => { + const state = createExceptionHandlerRuntimeState(); + const configured = new Error("configured"); - const handler = provider.$get(); + applyExceptionHandlerConfiguration(state, { + handler(): never { + throw configured; + }, + }); + applyExceptionHandlerConfiguration(state, {}); - provider.handler = () => { - throw "second"; - }; - - expect(() => handler("ignored")).toThrow("second"); - }); - - it("$get returns a function", () => { - expect(typeof provider.$get()).toBe("function"); + expect(() => state.service("ignored")).toThrow(configured); }); - it("$get returns a new wrapper function each time", () => { - const handler1 = provider.$get(); - - const handler2 = provider.$get(); - - expect(typeof handler1).toBe("function"); - expect(typeof handler2).toBe("function"); - expect(handler1).not.toBe(handler2); + it("disposes idempotently and rejects later use", () => { + const state = createExceptionHandlerRuntimeState(); + const service = createExceptionHandlerService(state); + + destroyExceptionHandlerRuntimeState(state); + destroyExceptionHandlerRuntimeState(state); + + expect(() => service("late")).toThrowError( + "Exception handler runtime has already been disposed.", + ); + expect(() => createExceptionHandlerService(state)).toThrowError( + "Exception handler runtime has already been disposed.", + ); + expect(() => applyExceptionHandlerConfiguration(state, {})).toThrowError( + "Exception handler runtime has already been disposed.", + ); }); }); diff --git a/src/services/exception/exception.ts b/src/services/exception/exception.ts index 638f5b0bd..6a96227cc 100644 --- a/src/services/exception/exception.ts +++ b/src/services/exception/exception.ts @@ -7,7 +7,8 @@ * * By default, `$exceptionHandler` simply rethrows the exception. This ensures fail-fast * behavior, making errors visible immediately in development and in unit tests. - * Applications may override this service to introduce custom error handling. + * Applications may configure or decorate this service to introduce custom + * error handling. * * ### Example: Custom `$exceptionHandler` * @@ -49,28 +50,84 @@ export type ExceptionHandler = (exception: unknown) => never; /** - * Provider for the `$exceptionHandler` service. - * - * The default implementation rethrows exceptions, enabling strict fail-fast behavior. - * Applications may replace the handler via by setting `errorHandler`property or by providing their own - * `$exceptionHandler` factory. + * Declarative configuration accepted by + * `NgModule.config({ $exceptionHandler: ... })`. */ -export class ExceptionHandlerProvider { - handler: ng.ExceptionHandlerService; - +export interface ExceptionHandlerConfig { /** - * Creates the provider with the default rethrowing exception handler. + * Handler used by `$exceptionHandler`. + * + * Custom handlers should rethrow after reporting because AngularTS treats + * `$exceptionHandler` as fail-fast. */ - constructor() { - this.handler = (exception) => { - throw exception; - }; + handler?: ExceptionHandler; +} + +/** + * Runtime state shared by the public service and early-composed framework + * consumers such as the router. + */ +/** @internal */ +export interface ExceptionHandlerRuntimeState { + handler: ng.ExceptionHandlerService; + service: ng.ExceptionHandlerService; + destroyed: boolean; +} + +function rethrowException(exception: unknown): never { + throw exception; +} + +/** @internal */ +export function createExceptionHandlerRuntimeState(): ExceptionHandlerRuntimeState { + const state = { + handler: rethrowException, + service: undefined as unknown as ng.ExceptionHandlerService, + destroyed: false, + }; + + state.service = (exception: unknown): never => { + if (state.destroyed) { + throw new Error("Exception handler runtime has already been disposed."); + } + + return state.handler(exception); + }; + + return state; +} + +/** @internal */ +export function applyExceptionHandlerConfiguration( + state: ExceptionHandlerRuntimeState, + config: ExceptionHandlerConfig, +): void { + if (state.destroyed) { + throw new Error("Exception handler runtime has already been disposed."); } - /** - * Returns the currently configured exception handler wrapper. - */ - $get(): ng.ExceptionHandlerService { - return (exception: unknown) => this.handler(exception); + if (config.handler !== undefined) { + state.handler = config.handler; } } + +/** @internal */ +export function createExceptionHandlerService( + state: ExceptionHandlerRuntimeState, +): ng.ExceptionHandlerService { + if (state.destroyed) { + throw new Error("Exception handler runtime has already been disposed."); + } + + return state.service; +} + +/** @internal */ +export function destroyExceptionHandlerRuntimeState( + state: ExceptionHandlerRuntimeState, +): void { + if (state.destroyed) return; + + state.destroyed = true; + state.handler = rethrowException; +} diff --git a/src/services/html-canvas/README.md b/src/services/html-canvas/README.md new file mode 100644 index 000000000..b2881e19d --- /dev/null +++ b/src/services/html-canvas/README.md @@ -0,0 +1,158 @@ +# HTML-in-Canvas Internals + +This directory owns the optional AngularTS integration contract for native +HTML-in-Canvas rendering. The implementation is active when explicitly +configured and when the browser exposes the native primitives. AngularTS does +not provide a fallback renderer. + +HTML-in-Canvas is not part of the default AngularTS runtime. It is available +only to custom builds that explicitly install `htmlCanvasModule`. + +## Responsibilities + +- Define the typed `$htmlCanvas` config surface. +- Detect native HTML-in-Canvas primitives without creating a fallback renderer. +- Register canvas root, source, and invalidation directives. +- Keep the integration out of the default runtime and disabled unless a custom + build opts in. +- Fail fast when active config is used on unsupported runtimes. +- Preserve the no-fallback contract for the experimental browser API. + +## Public Surface + +- `HtmlCanvasConfig`: typed config accepted by + `NgModule.config({ $htmlCanvas: ... })`. +- `ng.HtmlCanvasConfig`: namespace alias for generated and global typings. + +- `HtmlCanvasService`: injectable `$htmlCanvas` runtime owner for support, + root/source registration, and invalidation. + +The runtime directives are: + +- `ng-html-canvas` +- `ng-html-canvas-source` +- `ng-html-canvas-invalidate` + +Using those directives throws when `$htmlCanvas` is disabled or when the +configured native mode is unsupported. + +`ng-html-canvas-source` accepts numeric `x`, `y`, `width`, and `height` +attributes, plus `data-*` equivalents. AngularTS reads those values at draw +time, so interpolated attributes can move or resize a source without requiring +manual service calls. + +Custom builds opt in through the runtime slice: + +```ts +import { createAngular } from "@angular-wave/angular.ts/runtime"; +import { htmlCanvasModule } from "@angular-wave/angular.ts/runtime/html-canvas"; + +const angular = createAngular({ + modules: [htmlCanvasModule], +}); +``` + +## Core Model + +The current model is native-only: + +1. A custom runtime installs `htmlCanvasModule`. +2. Application code declares `$htmlCanvas` config. +3. `enabled: false` is accepted and produces no runtime work. +4. `enabled: true` requires the configured native mode and fails fast when + unsupported. +5. `enabled: "auto"` activates only when the configured native mode is + available. +6. Directive usage registers a canvas root, direct source children, and + invalidation scheduling through the native `paint` lifecycle. + +Important invariants: + +- AngularTS must not emulate HTML-in-Canvas. +- Active config must not silently degrade to DOM-only rendering. +- The source DOM tree remains normal Angular-bound DOM when runtime support is + eventually enabled. + +## Lifecycle Contract + +- Construction does not touch browser APIs. +- Runtime support detection is explicit and side-effect-light. +- Disabled config creates no provider, service, renderer, or scheduler. +- Active config is accepted during module config and enforced by `$htmlCanvas` + at runtime. +- Runtime activation must remain explicit and module-owned. + +## Runtime Support Gate + +`getHtmlCanvasRuntimeSupport(...)` detects the experimental primitives described +by the WICG/Chromium shape: + +- `` +- canvas `paint` event +- 2D `drawElementImage(...)` +- WebGL `texElementImage2D(...)` +- WebGPU `copyElementImageToTexture(...)` + +`assertHtmlCanvasRuntimeSupported(...)` is the runtime gate for active config. +It allows disabled config, accepts active config only when the requested native +mode exists, and throws with the no-fallback error otherwise. + +## Reactivity Contract + +- `$htmlCanvas` does not create app state. +- Source layers use normal Angular binding and scope semantics. +- Redraw scheduling is root-owned DOM work, not app model work. +- Source mutations and ResizeObserver changes invalidate the owning root. +- Native paint events that report changed descendants redraw the owning source + rather than requiring the changed element to be the source root itself. + +## Interaction Strategy + +Angular-bound controls remain normal DOM controls inside the canvas fallback +tree. AngularTS does not synthesize event coordinates, clone controls, or replay +DOM state into a framework-owned canvas tree. Native HTML-in-Canvas owns hit +testing, accessibility alignment, and rendered output. AngularTS owns the +source DOM binding, scope lifecycle, and explicit invalidation boundary. + +That means standard directives such as `ng-click` and `ng-model` bind to the +source DOM tree. The rendering slice only schedules redraw/invalidation work; it +does not replace Angular event or model semantics. + +## Policy Contract + +- Default policy is disabled. +- No fallback renderer is provided. +- `throwOnUnsupported` is required for active config. +- `requireFlag` defaults to strict feature-flag expectations while the browser + API is experimental. +- Enterprise interaction policy belongs in normal application code around the + source DOM controls. AngularTS should not create a separate canvas event + policy while native HTML-in-Canvas owns hit testing and control activation. + +## Dependency Replacement Contract + +- This replaces ad hoc canvas DOM rendering glue only after the runtime slice + exists. +- It builds on native HTML-in-Canvas browser behavior. +- It does not replace canvas/WebGL/WebGPU engines. +- Native browser failure remains visible to application code. + +## Failure Contract + +- Active config fails synchronously when the configured mode is unsupported and + `throwOnUnsupported` is true. +- Directive usage fails synchronously while the feature is disabled. +- Unsupported-browser failures are clear runtime errors. +- Fallback rendering is not allowed. + +## Executable Samples + +- `docs/static/examples/html-canvas/html-canvas.html` shows config and real + `` source markup. +- The same docs sample includes a policy-oriented interaction panel where + `ng-click` remains normal source-DOM behavior and application code accepts or + blocks the operation. +- `src/services/html-canvas/html-canvas.test.ts` loads the docs sample and the + unit spec so the runtime contract stays executable. +- `concepts/html-in-canvas/` demonstrates model-backed controls inside a native + HTML-in-Canvas source tree. diff --git a/src/services/html-canvas/html-canvas.html b/src/services/html-canvas/html-canvas.html new file mode 100644 index 000000000..45239b4e9 --- /dev/null +++ b/src/services/html-canvas/html-canvas.html @@ -0,0 +1,22 @@ + + + + + AngularTS HTML-in-Canvas Tests + + + + + + + + + + + +
+ + diff --git a/src/services/html-canvas/html-canvas.spec.ts b/src/services/html-canvas/html-canvas.spec.ts new file mode 100644 index 000000000..a471029dc --- /dev/null +++ b/src/services/html-canvas/html-canvas.spec.ts @@ -0,0 +1,886 @@ +/// +import { Angular } from "../../angular.ts"; +import { createAngular } from "../../runtime/index.ts"; +import { htmlCanvasModule } from "../../runtime/html-canvas.ts"; +import { dealoc } from "../../shared/dom.ts"; +import { + ngHtmlCanvasDirective, + ngHtmlCanvasInvalidateDirective, + ngHtmlCanvasSourceDirective, +} from "../../directive/html-canvas/html-canvas.ts"; +import { + assertHtmlCanvasConfigInactive, + applyHtmlCanvasConfiguration, + assertHtmlCanvasRuntimeSupported, + createHtmlCanvasRuntimeState, + createHtmlCanvasService, + destroyHtmlCanvasRuntimeState, + getHtmlCanvasRuntimeSupport, + NativeHtmlCanvasService, + htmlCanvasRuntimeDisabledMessage, + htmlCanvasRuntimeUnsupportedMessage, + normalizeHtmlCanvasConfig, + type HtmlCanvasRuntimeSupport, +} from "./html-canvas.ts"; + +type TestLinkFn = (scope: ng.Scope, element: Element) => void; + +const unsupportedRuntime: HtmlCanvasRuntimeSupport = { + layoutSubtree: false, + paintEvent: false, + requestPaint: false, + drawElementImage: false, + texElementImage2D: false, + copyElementImageToTexture: false, + modes: { + "2d": false, + webgl: false, + webgpu: false, + }, + supported: false, +}; + +const supported2dRuntime: HtmlCanvasRuntimeSupport = { + layoutSubtree: true, + paintEvent: true, + requestPaint: true, + drawElementImage: true, + texElementImage2D: false, + copyElementImageToTexture: false, + modes: { + "2d": true, + webgl: false, + webgpu: false, + }, + supported: true, +}; + +const supportedWebglRuntime: HtmlCanvasRuntimeSupport = { + ...supported2dRuntime, + drawElementImage: false, + texElementImage2D: true, + modes: { + "2d": false, + webgl: true, + webgpu: false, + }, +}; + +describe("HTML-in-Canvas", () => { + let $compile: ng.CompileService; + let $rootScope: ng.Scope; + + beforeEach(() => { + const app = document.getElementById("app") as HTMLElement; + + dealoc(app); + window.angular = new Angular(); + const injector = window.angular.bootstrap(app, []); + + injector.invoke((_$compile_, _$rootScope_) => { + $compile = _$compile_; + $rootScope = _$rootScope_; + }); + }); + + function bootstrapCustomHtmlCanvasRuntime(): void { + const app = document.getElementById("app") as HTMLElement; + + dealoc(app); + window.angular = createAngular({ + modules: [htmlCanvasModule], + }) as unknown as Angular; + + const injector = window.angular.bootstrap(app, []); + + injector.invoke((_$compile_, _$rootScope_) => { + $compile = _$compile_; + $rootScope = _$rootScope_; + }); + } + + it("reports native runtime support without enabling a fallback", () => { + const support = getHtmlCanvasRuntimeSupport(); + + expect(typeof support.layoutSubtree).toBe("boolean"); + expect(typeof support.paintEvent).toBe("boolean"); + expect(typeof support.drawElementImage).toBe("boolean"); + expect(typeof support.texElementImage2D).toBe("boolean"); + expect(typeof support.copyElementImageToTexture).toBe("boolean"); + expect(typeof support.supported).toBe("boolean"); + }); + + it("reports unsupported when no document or window is available", () => { + expect( + getHtmlCanvasRuntimeSupport({ document: null, window: null }), + ).toEqual(unsupportedRuntime); + }); + + it("reports 2d, webgl, and webgpu native mode support", () => { + const canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas", + ) as HTMLCanvasElement; + const fakeWindow = { + navigator: { gpu: {} }, + GPUQueue: { + prototype: { + copyElementImageToTexture() { + return undefined; + }, + }, + }, + } as unknown as Window; + const fakeDocument = { + createElementNS() { + Object.defineProperty(canvas, "layoutSubTree", { value: true }); + Object.defineProperty(canvas, "onpaint", { value: null }); + Object.defineProperty(canvas, "requestPaint", { + value() { + return undefined; + }, + }); + canvas.getContext = ((type: string) => { + if (type === "2d") { + return { + drawElementImage() { + return undefined; + }, + }; + } + + if (type === "webgl2") { + return { + texElementImage2D() { + return undefined; + }, + }; + } + + return null; + }) as HTMLCanvasElement["getContext"]; + + return canvas; + }, + } as unknown as Document; + + expect( + getHtmlCanvasRuntimeSupport({ + document: fakeDocument, + window: fakeWindow, + }), + ).toEqual({ + layoutSubtree: true, + paintEvent: true, + requestPaint: true, + drawElementImage: true, + texElementImage2D: true, + copyElementImageToTexture: true, + modes: { + "2d": true, + webgl: true, + webgpu: true, + }, + supported: true, + }); + }); + + it("reports unsupported when a document cannot create a canvas", () => { + const fakeDocument = { + createElementNS() { + return document.createElementNS("http://www.w3.org/1999/xhtml", "div"); + }, + } as unknown as Document; + + expect( + getHtmlCanvasRuntimeSupport({ + document: fakeDocument, + window: null, + }), + ).toEqual(unsupportedRuntime); + }); + + it("keeps inactive and active config accepted after the runtime slice lands", () => { + expect(() => + assertHtmlCanvasConfigInactive({ enabled: false }), + ).not.toThrow(); + expect(() => + assertHtmlCanvasConfigInactive({ + enabled: "auto", + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }), + ).not.toThrow(); + }); + + it("allows disabled config through the runtime gate", () => { + expect( + assertHtmlCanvasRuntimeSupported( + { enabled: false }, + { support: unsupportedRuntime }, + ), + ).toBe(unsupportedRuntime); + }); + + it("throws for active config when the browser has no native support", () => { + expect(() => + assertHtmlCanvasRuntimeSupported( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + { support: unsupportedRuntime }, + ), + ).toThrowError(`${htmlCanvasRuntimeUnsupportedMessage} Missing 2d.`); + }); + + it("allows auto config to remain inactive when unsupported and configured not to throw", () => { + expect( + assertHtmlCanvasRuntimeSupported( + { + enabled: "auto", + throwOnUnsupported: false, + defaultScheduler: "paint", + defaultMode: "2d", + }, + { support: unsupportedRuntime }, + ), + ).toBe(unsupportedRuntime); + }); + + it("accepts active config only when the requested native mode exists", () => { + expect( + assertHtmlCanvasRuntimeSupported( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + { support: supported2dRuntime }, + ), + ).toBe(supported2dRuntime); + }); + + it("checks the current runtime when no support override is provided", () => { + expect(() => + assertHtmlCanvasRuntimeSupported({ + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "webgpu", + }), + ).toThrowError(`${htmlCanvasRuntimeUnsupportedMessage} Missing webgpu.`); + }); + + it("keeps directives fail-fast when html canvas is not configured", () => { + bootstrapCustomHtmlCanvasRuntime(); + + expect(() => + $compile("")($rootScope), + ).toThrowError(htmlCanvasRuntimeDisabledMessage); + expect(() => + $compile( + "
", + )($rootScope), + ).toThrowError(htmlCanvasRuntimeDisabledMessage); + }); + + it("registers disabled source invalidation directives", () => { + bootstrapCustomHtmlCanvasRuntime(); + + expect(() => + $compile("
")( + $rootScope, + ), + ).toThrowError(htmlCanvasRuntimeDisabledMessage); + }); + + it("links canvas roots and disposes them with their scope", () => { + const dispose = jasmine.createSpy("dispose"); + const registerRoot = jasmine + .createSpy("registerRoot") + .and.returnValue({ dispose }); + const directive = ngHtmlCanvasDirective({ + registerRoot, + } as unknown as ng.HtmlCanvasService); + const canvas = document.createElement("canvas"); + const scope = $rootScope.$new(); + const link = directive.compile?.(canvas) as TestLinkFn; + + expect(canvas.getAttribute("layoutsubtree")).toBe("true"); + + link(scope, canvas); + expect(registerRoot).toHaveBeenCalledOnceWith(canvas); + + scope.$destroy(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it("allows the root directive contract to validate non-canvas elements", () => { + const registerRoot = jasmine + .createSpy("registerRoot") + .and.returnValue({ dispose: jasmine.createSpy("dispose") }); + const directive = ngHtmlCanvasDirective({ + registerRoot, + } as unknown as ng.HtmlCanvasService); + const element = document.createElement("div"); + const scope = $rootScope.$new(); + const link = directive.compile?.( + element as unknown as HTMLCanvasElement, + ) as TestLinkFn; + + expect(element.hasAttribute("layoutsubtree")).toBeFalse(); + link(scope, element); + expect(registerRoot).toHaveBeenCalledOnceWith(element); + }); + + it("registers source geometry from standard and data attributes", () => { + const destroy = jasmine.createSpy("destroy"); + const registerSource = jasmine + .createSpy("registerSource") + .and.returnValue(destroy); + const directive = ngHtmlCanvasSourceDirective({ + registerSource, + } as unknown as ng.HtmlCanvasService); + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const scope = $rootScope.$new(); + + source.setAttribute("x", "12"); + source.setAttribute("data-y", "3"); + source.setAttribute("width", " "); + source.setAttribute("height", "invalid"); + canvas.append(source); + + (directive.link as TestLinkFn)(scope, source); + + const options = registerSource.calls.mostRecent().args[2]; + + expect(registerSource.calls.mostRecent().args.slice(0, 2)).toEqual([ + canvas, + source, + ]); + expect(options.x).toBe(12); + expect(options.y).toBe(3); + expect(options.width).toBeUndefined(); + expect(options.height).toBeUndefined(); + + source.setAttribute("width", "20"); + source.setAttribute("height", "30"); + expect(options.width).toBe(20); + expect(options.height).toBe(30); + + scope.$destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("accepts a canvas as its own source root", () => { + const registerSource = jasmine + .createSpy("registerSource") + .and.returnValue(jasmine.createSpy("destroy")); + const directive = ngHtmlCanvasSourceDirective({ + registerSource, + } as unknown as ng.HtmlCanvasService); + const canvas = document.createElement("canvas"); + + (directive.link as TestLinkFn)($rootScope.$new(), canvas); + + expect(registerSource.calls.mostRecent().args.slice(0, 2)).toEqual([ + canvas, + canvas, + ]); + }); + + it("rejects source and invalidation directives outside a canvas", () => { + const service = { + invalidate: jasmine.createSpy("invalidate"), + registerSource: jasmine.createSpy("registerSource"), + } as unknown as ng.HtmlCanvasService; + const sourceDirective = ngHtmlCanvasSourceDirective(service); + const invalidateDirective = ngHtmlCanvasInvalidateDirective(service); + const element = document.createElement("div"); + + expect(() => + (sourceDirective.link as TestLinkFn)($rootScope.$new(), element), + ).toThrowError( + "HTML-in-Canvas source and invalidation directives require a parent canvas root.", + ); + expect(() => + (invalidateDirective.link as TestLinkFn)($rootScope, element), + ).toThrowError( + "HTML-in-Canvas source and invalidation directives require a parent canvas root.", + ); + }); + + it("invalidates canvas roots directly and through child elements", () => { + const invalidate = jasmine.createSpy("invalidate"); + const directive = ngHtmlCanvasInvalidateDirective({ + invalidate, + } as unknown as ng.HtmlCanvasService); + const canvas = document.createElement("canvas"); + const child = document.createElement("div"); + + canvas.append(child); + (directive.link as TestLinkFn)($rootScope, canvas); + (directive.link as TestLinkFn)($rootScope, child); + + expect(invalidate.calls.allArgs()).toEqual([[canvas], [canvas]]); + }); + + it("keeps the service absent when the custom runtime omits the slice", () => { + const app = document.getElementById("app") as HTMLElement; + + dealoc(app); + const angular = createAngular(); + const injector = angular.bootstrap(app, []); + + expect(injector.has("$htmlCanvas")).toBeFalse(); + }); + + it("owns service construction and teardown in the runtime state", () => { + const state = createHtmlCanvasRuntimeState(); + + applyHtmlCanvasConfiguration(state, { enabled: false }); + + const service = createHtmlCanvasService(state, window, document); + + expect(service.enabled).toBeFalse(); + expect(createHtmlCanvasService(state, window, document)).toBe(service); + + destroyHtmlCanvasRuntimeState(state); + destroyHtmlCanvasRuntimeState(state); + + expect(() => + service.registerRoot(document.createElement("canvas")), + ).toThrowError("HTML-in-Canvas runtime has already been disposed."); + expect(() => createHtmlCanvasService(state, window, document)).toThrowError( + "HTML-in-Canvas runtime has already been disposed.", + ); + expect(() => + applyHtmlCanvasConfiguration(state, { enabled: false }), + ).toThrowError("HTML-in-Canvas runtime has already been disposed."); + }); + + it("applies typed module config through the custom runtime registrar", () => { + const app = document.getElementById("app") as HTMLElement; + + dealoc(app); + const angular = createAngular({ + modules: [htmlCanvasModule], + }); + + angular.module("configuredHtmlCanvas", []).config({ + $htmlCanvas: { + enabled: false, + defaultMode: "webgpu", + defaultScheduler: "raf", + }, + }); + + const injector = angular.bootstrap(app, ["configuredHtmlCanvas"]); + const service = injector.get("$htmlCanvas"); + + expect(service.config).toEqual({ + enabled: false, + throwOnUnsupported: true, + defaultScheduler: "raf", + defaultMode: "webgpu", + requireFlag: true, + }); + + angular._composition.destroy(); + + expect(() => + service.invalidate(document.createElement("canvas")), + ).toThrowError("HTML-in-Canvas runtime has already been disposed."); + }); + + it("registers roots, sources, and schedules native paint requests", () => { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const drawElementImage = jasmine + .createSpy("drawElementImage") + .and.returnValue("matrix(1, 0, 0, 1, 0, 0)"); + const requestPaint = jasmine.createSpy("requestPaint"); + + canvas.append(source); + Object.defineProperty(canvas, "requestPaint", { value: requestPaint }); + canvas.getContext = ((type: string) => { + if (type !== "2d") return null; + + return { + drawElementImage, + reset() { + return undefined; + }, + }; + }) as HTMLCanvasElement["getContext"]; + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: supported2dRuntime }, + ); + + const root = service.registerRoot(canvas); + const destroy = service.registerSource(canvas, source); + + expect(root.canvas).toBe(canvas); + expect(canvas.getAttribute("layoutsubtree")).toBe("true"); + expect(requestPaint).toHaveBeenCalled(); + + canvas.dispatchEvent( + new CustomEvent("paint", { + detail: undefined, + }), + ); + + expect(drawElementImage).toHaveBeenCalledWith(source, 0, 0); + expect(source.style.transform).toBe("matrix(1, 0, 0, 1, 0, 0)"); + + destroy(); + service.dispose(); + + expect(() => service.registerRoot(canvas)).toThrowError( + "HTML-in-Canvas runtime has already been disposed.", + ); + }); + + it("draws registered sources with explicit source rectangles", () => { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const drawElementImage = jasmine.createSpy("drawElementImage"); + const requestPaint = jasmine.createSpy("requestPaint"); + + canvas.append(source); + Object.defineProperty(canvas, "requestPaint", { value: requestPaint }); + canvas.getContext = ((type: string) => { + if (type !== "2d") return null; + + return { + drawElementImage, + reset() { + return undefined; + }, + }; + }) as HTMLCanvasElement["getContext"]; + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: supported2dRuntime }, + ); + + const root = service.registerRoot(canvas); + const destroy = service.registerSource(canvas, source, { + x: 12, + y: 24, + width: 240, + height: 120, + }); + + canvas.dispatchEvent(new Event("paint")); + + expect(drawElementImage).toHaveBeenCalledWith(source, 12, 24, 240, 120); + + destroy(); + root.dispose(); + }); + + it("redraws a source when a paint event reports a changed descendant", () => { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const child = document.createElement("span"); + const otherSource = document.createElement("aside"); + const drawElementImage = jasmine.createSpy("drawElementImage"); + + source.append(child); + canvas.append(source, otherSource); + Object.defineProperty(canvas, "requestPaint", { + value() { + return undefined; + }, + }); + canvas.getContext = ((type: string) => { + if (type !== "2d") return null; + + return { + drawElementImage, + reset() { + return undefined; + }, + }; + }) as HTMLCanvasElement["getContext"]; + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: supported2dRuntime }, + ); + + const root = service.registerRoot(canvas); + const destroySource = service.registerSource(canvas, source); + const destroyOtherSource = service.registerSource(canvas, otherSource); + const event = new Event("paint"); + + Object.defineProperty(event, "changedElements", { value: [child] }); + canvas.dispatchEvent(event); + + expect(drawElementImage).toHaveBeenCalledOnceWith(source, 0, 0); + + destroySource(); + destroyOtherSource(); + root.dispose(); + }); + + it("observes source changes and disconnects observers owned by a root", () => { + let mutationCallback: MutationCallback | undefined; + let resizeCallback: ResizeObserverCallback | undefined; + const mutationDisconnect = jasmine.createSpy("mutationDisconnect"); + const resizeDisconnect = jasmine.createSpy("resizeDisconnect"); + const originalMutationObserver = globalThis.MutationObserver; + const originalResizeObserver = globalThis.ResizeObserver; + + class TestMutationObserver { + constructor(callback: MutationCallback) { + mutationCallback = callback; + } + + observe(): void {} + disconnect(): void { + mutationDisconnect(); + } + } + + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe(): void {} + disconnect(): void { + resizeDisconnect(); + } + } + + Object.defineProperty(globalThis, "MutationObserver", { + configurable: true, + value: TestMutationObserver, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, + }); + + try { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const requestPaint = jasmine.createSpy("requestPaint"); + + canvas.append(source); + Object.defineProperty(canvas, "requestPaint", { value: requestPaint }); + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: supported2dRuntime }, + ); + const root = service.registerRoot(canvas); + + service.registerSource(canvas, source); + const invalidate = spyOn(root, "invalidate"); + + mutationCallback?.([], {} as MutationObserver); + resizeCallback?.([], {} as ResizeObserver); + + expect(invalidate).toHaveBeenCalledTimes(2); + + root.dispose(); + root.dispose(); + + expect(mutationDisconnect).toHaveBeenCalledTimes(1); + expect(resizeDisconnect).toHaveBeenCalledTimes(1); + expect(() => + ( + root as unknown as { + addSource(element: Element, options: object): () => void; + } + ).addSource(source, {}), + ).toThrowError("HTML-in-Canvas root has already been disposed."); + } finally { + Object.defineProperty(globalThis, "MutationObserver", { + configurable: true, + value: originalMutationObserver, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: originalResizeObserver, + }); + } + }); + + it("schedules paint through animation frames and ignores disposed frames", () => { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const requestPaint = jasmine.createSpy("requestPaint"); + const frames: FrameRequestCallback[] = []; + const testWindow = { + requestAnimationFrame(callback: FrameRequestCallback) { + frames.push(callback); + return frames.length; + }, + } as unknown as Window; + + canvas.append(source); + Object.defineProperty(canvas, "requestPaint", { value: requestPaint }); + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "raf", + defaultMode: "2d", + }, + testWindow, + document, + { support: supported2dRuntime }, + ); + const root = service.registerRoot(canvas); + + service.registerSource(canvas, source); + expect(requestPaint).not.toHaveBeenCalled(); + + frames.shift()?.(0); + expect(requestPaint).toHaveBeenCalledTimes(1); + + root.invalidate(); + root.dispose(); + frames.shift()?.(1); + + expect(requestPaint).toHaveBeenCalledTimes(1); + }); + + it("validates roots, paint methods, and rendering modes", () => { + const canvas = document.createElement("canvas"); + const source = document.createElement("div"); + const outsider = document.createElement("div"); + const requestPaint = jasmine.createSpy("requestPaint"); + + canvas.append(source); + Object.defineProperty(canvas, "requestPaint", { value: requestPaint }); + + const service = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: supported2dRuntime }, + ); + const root = service.registerRoot(canvas); + + expect(service.registerRoot(canvas)).toBe(root); + expect(() => service.registerSource(canvas, outsider)).toThrowError( + "ng-html-canvas-source must be a direct child of the ng-html-canvas root.", + ); + expect(() => + service.invalidate(document.createElement("canvas")), + ).not.toThrow(); + expect(() => + service.requestPaint(document.createElement("canvas")), + ).toThrowError(`${htmlCanvasRuntimeUnsupportedMessage} Missing paint.`); + + canvas.getContext = (() => null) as HTMLCanvasElement["getContext"]; + expect(() => + ( + root as unknown as { + _paint(event: Event): void; + } + )._paint(new Event("paint")), + ).toThrowError(`${htmlCanvasRuntimeUnsupportedMessage} Missing 2d.`); + + const webglService = new NativeHtmlCanvasService( + { + enabled: true, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "webgl", + }, + window, + document, + { support: supportedWebglRuntime }, + ); + const webglRoot = webglService.registerRoot( + document.createElement("canvas"), + ); + + expect(() => + ( + webglRoot as unknown as { + _paint(event: Event): void; + } + )._paint(new Event("paint")), + ).not.toThrow(); + }); + + it("normalizes defaults and rejects inactive auto runtimes at use time", () => { + expect(normalizeHtmlCanvasConfig()).toEqual({ + enabled: false, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + requireFlag: true, + }); + + const service = new NativeHtmlCanvasService( + { + enabled: "auto", + throwOnUnsupported: false, + defaultScheduler: "paint", + defaultMode: "2d", + }, + window, + document, + { support: unsupportedRuntime }, + ); + + expect(service.enabled).toBeFalse(); + expect(() => + service.registerRoot(document.createElement("canvas")), + ).toThrowError(`${htmlCanvasRuntimeUnsupportedMessage} Missing 2d.`); + + service.dispose(); + service.dispose(); + }); +}); diff --git a/src/services/html-canvas/html-canvas.test.ts b/src/services/html-canvas/html-canvas.test.ts new file mode 100644 index 000000000..fb99889ed --- /dev/null +++ b/src/services/html-canvas/html-canvas.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; + +const TEST_URL = "src/services/html-canvas/html-canvas.html?random=false"; +const DOCS_EXAMPLE_URL = + "docs/static/examples/html-canvas/html-canvas.html?random=false"; + +test("unit tests contain no errors", async ({ page }) => { + await expectNoJasmineFailures(page, TEST_URL); +}); + +test("docs example boots with native-only auto config", async ({ page }) => { + await page.goto(DOCS_EXAMPLE_URL); + await expect(page.getByTestId("ship-name")).toHaveText("The Canvas Voyager"); + await expect(page.getByTestId("html-canvas-status")).toContainText( + "HTML-in-Canvas native runtime", + ); +}); + +test("docs example keeps enterprise interaction policy in application code", async ({ + page, +}) => { + await page.goto(DOCS_EXAMPLE_URL); + + await expect(page.getByTestId("policy-mode")).toHaveText("explicit-approval"); + await page + .getByTestId("approve-action") + .evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.getByTestId("policy-status")).toHaveText( + "Interaction blocked by application policy.", + ); + + await page + .getByTestId("approval-code") + .evaluate((input: HTMLInputElement) => { + input.value = "LAUNCH"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await page + .getByTestId("approve-action") + .evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.getByTestId("policy-status")).toHaveText( + "Interaction accepted by application policy.", + ); +}); diff --git a/src/services/html-canvas/html-canvas.ts b/src/services/html-canvas/html-canvas.ts new file mode 100644 index 000000000..f49fe7591 --- /dev/null +++ b/src/services/html-canvas/html-canvas.ts @@ -0,0 +1,661 @@ +/** Scheduler used by the native HTML-in-Canvas renderer. */ +export type HtmlCanvasScheduler = "paint" | "raf"; + +/** Rendering target requested by an HTML-in-Canvas root. */ +export type HtmlCanvasMode = "2d" | "webgl" | "webgpu"; + +export interface HtmlCanvasRuntimeSupport { + /** Native layout-subtree support or an implied drawing primitive. */ + layoutSubtree: boolean; + + /** Native canvas `paint` event support. */ + paintEvent: boolean; + + /** Native canvas `requestPaint()` support. */ + requestPaint: boolean; + + /** Native 2D `drawElementImage(...)` support. */ + drawElementImage: boolean; + + /** Native WebGL `texElementImage2D(...)` support. */ + texElementImage2D: boolean; + + /** Native WebGPU `copyElementImageToTexture(...)` support. */ + copyElementImageToTexture: boolean; + + /** Supported rendering modes for the current runtime. */ + modes: Record; + + /** Whether any native HTML-in-Canvas rendering mode is available. */ + supported: boolean; +} + +export interface HtmlCanvasRuntimeSupportOptions { + document?: Document | null; + window?: Window | null; + support?: HtmlCanvasRuntimeSupport; +} + +export interface HtmlCanvasSourceOptions { + x?: number; + y?: number; + width?: number; + height?: number; +} + +export interface HtmlCanvasRootOptions { + mode?: HtmlCanvasMode; + scheduler?: HtmlCanvasScheduler; +} + +export interface HtmlCanvasRoot { + readonly canvas: HTMLCanvasElement; + readonly mode: HtmlCanvasMode; + readonly scheduler: HtmlCanvasScheduler; + invalidate(): void; + dispose(): void; +} + +export interface HtmlCanvasService { + readonly config: NormalizedHtmlCanvasConfig; + readonly support: HtmlCanvasRuntimeSupport; + readonly enabled: boolean; + readonly supported: boolean; + registerRoot( + canvas: HTMLCanvasElement, + options?: HtmlCanvasRootOptions, + ): HtmlCanvasRoot; + registerSource( + canvas: HTMLCanvasElement, + source: Element, + options?: HtmlCanvasSourceOptions, + ): () => void; + invalidate(canvas: HTMLCanvasElement): void; + requestPaint(canvas: HTMLCanvasElement): void; +} + +interface HtmlCanvasConfigBase { + /** + * Throw when HTML-in-Canvas is enabled on a runtime that does not support the + * native browser feature. AngularTS does not provide a fallback renderer. + */ + throwOnUnsupported?: boolean; + + /** Default invalidation scheduler for canvas-backed HTML layers. */ + defaultScheduler?: HtmlCanvasScheduler; + + /** Default canvas rendering target for directives that do not specify one. */ + defaultMode?: HtmlCanvasMode; + + /** + * Require an explicit browser/engine feature flag before activation. + * This stays strict by default while the browser API is experimental. + */ + requireFlag?: boolean; +} + +/** Disabled or omitted HTML-in-Canvas config. */ +export interface HtmlCanvasDisabledConfig extends HtmlCanvasConfigBase { + enabled?: false; +} + +/** Active HTML-in-Canvas config. */ +export interface HtmlCanvasActiveConfig extends HtmlCanvasConfigBase { + enabled: true | "auto"; + throwOnUnsupported: boolean; + defaultScheduler: HtmlCanvasScheduler; + defaultMode: HtmlCanvasMode; +} + +/** + * Declarative config accepted by `NgModule.config({ $htmlCanvas: ... })`. + * + * The integration is disabled by default and has no AngularTS fallback. + */ +export type HtmlCanvasConfig = + | HtmlCanvasDisabledConfig + | HtmlCanvasActiveConfig; + +export interface NormalizedHtmlCanvasConfig { + enabled: false | true | "auto"; + throwOnUnsupported: boolean; + defaultScheduler: HtmlCanvasScheduler; + defaultMode: HtmlCanvasMode; + requireFlag: boolean; +} + +export const htmlCanvasRuntimeDisabledMessage = + "HTML-in-Canvas is disabled; configure $htmlCanvas.enabled before using canvas rendering directives."; + +export const htmlCanvasRuntimeUnsupportedMessage = + "HTML-in-Canvas requires native browser support; AngularTS does not provide a fallback renderer."; + +const defaultHtmlCanvasConfig: NormalizedHtmlCanvasConfig = { + enabled: false, + throwOnUnsupported: true, + defaultScheduler: "paint", + defaultMode: "2d", + requireFlag: true, +}; + +type ExperimentalCanvas = HTMLCanvasElement & { + layoutSubTree?: boolean; + layoutSubtree?: boolean; + requestPaint?: () => void; +}; + +type ExperimentalPaintEvent = Event & { + changedElements?: readonly Element[]; +}; + +type Experimental2DContext = CanvasRenderingContext2D & { + drawElementImage?: ( + element: Element, + ...args: [number, number] | [number, number, number, number] + ) => DOMMatrix | string | undefined; + reset?: () => void; +}; + +interface RegisteredSource { + element: Element; + options: HtmlCanvasSourceOptions; + observer?: MutationObserver; + resizeObserver?: ResizeObserver; +} + +class NativeHtmlCanvasRoot implements HtmlCanvasRoot { + readonly canvas: HTMLCanvasElement; + readonly mode: HtmlCanvasMode; + readonly scheduler: HtmlCanvasScheduler; + + private readonly _service: NativeHtmlCanvasService; + private readonly _onDispose: () => void; + private readonly _sources = new Set(); + private _disposed = false; + private _paintQueued = false; + private readonly _paintListener = (event: Event) => { + this._paintQueued = false; + this._paint(event as ExperimentalPaintEvent); + }; + + constructor( + service: NativeHtmlCanvasService, + canvas: HTMLCanvasElement, + options: Required, + onDispose: () => void, + ) { + this._service = service; + this._onDispose = onDispose; + this.canvas = canvas; + this.mode = options.mode; + this.scheduler = options.scheduler; + this.canvas.setAttribute("layoutsubtree", "true"); + (this.canvas as ExperimentalCanvas).layoutSubTree = true; + this.canvas.addEventListener("paint", this._paintListener); + } + + addSource(source: Element, options: HtmlCanvasSourceOptions): () => void { + if (this._disposed) { + throw new Error("HTML-in-Canvas root has already been disposed."); + } + + if (source.parentElement !== this.canvas) { + throw new Error( + "ng-html-canvas-source must be a direct child of the ng-html-canvas root.", + ); + } + + const entry: RegisteredSource = { element: source, options }; + + if (typeof MutationObserver !== "undefined") { + entry.observer = new MutationObserver(() => { + this.invalidate(); + }); + entry.observer.observe(source, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + } + + if (typeof ResizeObserver !== "undefined") { + entry.resizeObserver = new ResizeObserver(() => { + this.invalidate(); + }); + entry.resizeObserver.observe(source); + } + + this._sources.add(entry); + this.invalidate(); + + return () => { + entry.observer?.disconnect(); + entry.resizeObserver?.disconnect(); + this._sources.delete(entry); + this.invalidate(); + }; + } + + invalidate(): void { + if (this._disposed || this._paintQueued) return; + + this._paintQueued = true; + + if (this.scheduler === "raf") { + this._service.requestAnimationFrame(() => { + if (this._disposed) return; + + this._paintQueued = false; + this._service.requestPaint(this.canvas); + }); + + return; + } + + this._service.requestPaint(this.canvas); + } + + dispose(): void { + if (this._disposed) return; + + this._disposed = true; + this.canvas.removeEventListener("paint", this._paintListener); + + for (const source of this._sources) { + source.observer?.disconnect(); + source.resizeObserver?.disconnect(); + } + + this._sources.clear(); + this._onDispose(); + } + + private _paint(event: ExperimentalPaintEvent): void { + if (this.mode !== "2d") { + return; + } + + const changed = event.changedElements; + const context = this.canvas.getContext( + "2d", + ) as Experimental2DContext | null; + + if (!context?.drawElementImage) { + throw new Error(`${htmlCanvasRuntimeUnsupportedMessage} Missing 2d.`); + } + + context.reset(); + + for (const source of this._sources) { + if (!shouldDrawSource(source.element, changed)) { + continue; + } + + const transform = drawSource(context, context.drawElementImage, source); + + if (transform !== undefined && source.element instanceof HTMLElement) { + source.element.style.transform = String(transform); + } + } + } +} + +function shouldDrawSource( + source: Element, + changedElements?: readonly Element[], +): boolean { + if (!changedElements?.length) return true; + + return changedElements.some((changed) => { + return changed === source || source.contains(changed); + }); +} + +function drawSource( + context: Experimental2DContext, + drawElementImage: NonNullable, + source: RegisteredSource, +): DOMMatrix | string | undefined { + const x = source.options.x ?? 0; + const y = source.options.y ?? 0; + + if ( + source.options.width !== undefined && + source.options.height !== undefined + ) { + return drawElementImage.call( + context, + source.element, + x, + y, + source.options.width, + source.options.height, + ); + } + + return drawElementImage.call(context, source.element, x, y); +} + +function isCallable(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === "function"; +} + +function createCanvas( + doc: Document | null | undefined, +): HTMLCanvasElement | undefined { + if (!doc || typeof HTMLCanvasElement === "undefined") return undefined; + + const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + + return canvas instanceof HTMLCanvasElement ? canvas : undefined; +} + +function getCanvas2dSupport(canvas: HTMLCanvasElement | undefined): boolean { + if (!canvas) return false; + + const context = canvas.getContext("2d") as + | (CanvasRenderingContext2D & { drawElementImage?: unknown }) + | null + | undefined; + + return isCallable(context?.drawElementImage); +} + +function getWebGlSupport(canvas: HTMLCanvasElement | undefined): boolean { + if (!canvas) return false; + + const context = (canvas.getContext("webgl2") ?? + canvas.getContext("webgl")) as + | (WebGLRenderingContext & { texElementImage2D?: unknown }) + | null + | undefined; + + return isCallable(context?.texElementImage2D); +} + +function getWebGpuSupport(win: Window | null | undefined): boolean { + if (!win) return false; + + const target = win as Window & { + GPUQueue?: { prototype?: Record }; + navigator: Navigator & { gpu?: unknown }; + }; + const queue = target.GPUQueue; + + return Boolean( + target.navigator.gpu && + queue?.prototype && + isCallable(queue.prototype.copyElementImageToTexture), + ); +} + +export function getHtmlCanvasRuntimeSupport( + options: HtmlCanvasRuntimeSupportOptions = {}, +): HtmlCanvasRuntimeSupport { + if (options.support) { + return options.support; + } + + const doc = "document" in options ? options.document : globalThis.document; + const win = "window" in options ? options.window : globalThis.window; + const canvas = createCanvas(doc); + const drawElementImage = getCanvas2dSupport(canvas); + const texElementImage2D = getWebGlSupport(canvas); + const copyElementImageToTexture = getWebGpuSupport(win); + const paintEvent = Boolean(canvas && "onpaint" in canvas); + const requestPaint = canvas + ? isCallable((canvas as ExperimentalCanvas).requestPaint) + : false; + const layoutSubtree = Boolean( + canvas && + ("layoutSubTree" in canvas || + "layoutSubtree" in canvas || + drawElementImage || + texElementImage2D || + copyElementImageToTexture), + ); + const modes = { + "2d": layoutSubtree && paintEvent && requestPaint && drawElementImage, + webgl: layoutSubtree && paintEvent && requestPaint && texElementImage2D, + webgpu: + layoutSubtree && paintEvent && requestPaint && copyElementImageToTexture, + }; + + return { + layoutSubtree, + paintEvent, + requestPaint, + drawElementImage, + texElementImage2D, + copyElementImageToTexture, + modes, + supported: modes["2d"] || modes.webgl || modes.webgpu, + }; +} + +export function assertHtmlCanvasConfigInactive(config: HtmlCanvasConfig): void { + void config; +} + +export function normalizeHtmlCanvasConfig( + config: HtmlCanvasConfig | NormalizedHtmlCanvasConfig = {}, +): NormalizedHtmlCanvasConfig { + return { + ...defaultHtmlCanvasConfig, + ...config, + }; +} + +export function assertHtmlCanvasRuntimeSupported( + config: HtmlCanvasConfig | NormalizedHtmlCanvasConfig, + options: HtmlCanvasRuntimeSupportOptions = {}, +): HtmlCanvasRuntimeSupport { + const support = getHtmlCanvasRuntimeSupport(options); + const normalized = normalizeHtmlCanvasConfig(config); + + if (normalized.enabled === false) { + return support; + } + + const mode = normalized.defaultMode; + + if (!support.modes[mode]) { + if (normalized.enabled === "auto" && !normalized.throwOnUnsupported) { + return support; + } + + throw new Error(`${htmlCanvasRuntimeUnsupportedMessage} Missing ${mode}.`); + } + + return support; +} + +export class NativeHtmlCanvasService implements HtmlCanvasService { + readonly config: NormalizedHtmlCanvasConfig; + readonly support: HtmlCanvasRuntimeSupport; + readonly enabled: boolean; + readonly supported: boolean; + + private readonly _roots = new WeakMap< + HTMLCanvasElement, + NativeHtmlCanvasRoot + >(); + private readonly _ownedRoots = new Set(); + private readonly _window: Window; + private _disposed = false; + + constructor( + config: HtmlCanvasConfig | NormalizedHtmlCanvasConfig, + win: Window, + doc: Document, + supportOptions: HtmlCanvasRuntimeSupportOptions = {}, + ) { + this._window = win; + this.config = normalizeHtmlCanvasConfig(config); + this.support = assertHtmlCanvasRuntimeSupported(this.config, { + document: doc, + window: win, + ...supportOptions, + }); + this.supported = this.support.modes[this.config.defaultMode]; + this.enabled = + this.config.enabled === true || + (this.config.enabled === "auto" && this.supported); + } + + registerRoot( + canvas: HTMLCanvasElement, + options: HtmlCanvasRootOptions = {}, + ): HtmlCanvasRoot { + this.assertActive(); + this.assertEnabled(); + + const existing = this._roots.get(canvas); + + if (existing) return existing; + + const root = new NativeHtmlCanvasRoot( + this, + canvas, + { + mode: options.mode ?? this.config.defaultMode, + scheduler: options.scheduler ?? this.config.defaultScheduler, + }, + () => { + this._roots.delete(canvas); + this._ownedRoots.delete(root); + }, + ); + + this._roots.set(canvas, root); + this._ownedRoots.add(root); + + return root; + } + + registerSource( + canvas: HTMLCanvasElement, + source: Element, + options: HtmlCanvasSourceOptions = {}, + ): () => void { + const root = this.getRoot(canvas); + + return root.addSource(source, options); + } + + invalidate(canvas: HTMLCanvasElement): void { + this.assertActive(); + this.assertEnabled(); + this._roots.get(canvas)?.invalidate(); + } + + requestPaint(canvas: HTMLCanvasElement): void { + this.assertActive(); + const requestPaint = (canvas as ExperimentalCanvas).requestPaint; + + if (!isCallable(requestPaint)) { + throw new Error(`${htmlCanvasRuntimeUnsupportedMessage} Missing paint.`); + } + + requestPaint.call(canvas); + } + + requestAnimationFrame(callback: () => void): void { + this.assertActive(); + this._window.requestAnimationFrame(callback); + } + + /** @internal */ + dispose(): void { + if (this._disposed) return; + + this._disposed = true; + + for (const root of Array.from(this._ownedRoots)) root.dispose(); + + this._ownedRoots.clear(); + } + + private getRoot(canvas: HTMLCanvasElement): NativeHtmlCanvasRoot { + const existing = this._roots.get(canvas); + + if (existing) return existing; + + return this.registerRoot(canvas) as NativeHtmlCanvasRoot; + } + + private assertEnabled(): void { + if (this.enabled) return; + + if (this.config.enabled === false) { + throw new Error(htmlCanvasRuntimeDisabledMessage); + } + + throw new Error( + `${htmlCanvasRuntimeUnsupportedMessage} Missing ${this.config.defaultMode}.`, + ); + } + + private assertActive(): void { + if (this._disposed) { + throw new Error("HTML-in-Canvas runtime has already been disposed."); + } + } +} + +/** @internal */ +export interface HtmlCanvasRuntimeState { + config: NormalizedHtmlCanvasConfig; + service?: NativeHtmlCanvasService; + destroyed: boolean; +} + +/** @internal */ +export function createHtmlCanvasRuntimeState(): HtmlCanvasRuntimeState { + return { + config: defaultHtmlCanvasConfig, + destroyed: false, + }; +} + +/** @internal */ +export function applyHtmlCanvasConfiguration( + state: HtmlCanvasRuntimeState, + config: HtmlCanvasConfig, +): void { + if (state.destroyed) { + throw new Error("HTML-in-Canvas runtime has already been disposed."); + } + + state.config = normalizeHtmlCanvasConfig({ + ...state.config, + ...config, + }); +} + +/** @internal */ +export function createHtmlCanvasService( + state: HtmlCanvasRuntimeState, + win: Window, + doc: Document, +): HtmlCanvasService { + if (state.destroyed) { + throw new Error("HTML-in-Canvas runtime has already been disposed."); + } + + state.service ??= new NativeHtmlCanvasService(state.config, win, doc); + + return state.service; +} + +/** @internal */ +export function destroyHtmlCanvasRuntimeState( + state: HtmlCanvasRuntimeState, +): void { + if (state.destroyed) return; + + state.destroyed = true; + state.service?.dispose(); + state.service = undefined; +} diff --git a/src/services/http/http.spec.ts b/src/services/http/http.spec.ts index 7d8f30f38..4df050f23 100644 --- a/src/services/http/http.spec.ts +++ b/src/services/http/http.spec.ts @@ -5,15 +5,79 @@ import { createScope } from "../../core/scope/scope.ts"; import { hashKey, isObject } from "../../shared/utils.ts"; import { Angular } from "../../angular.ts"; import { wait } from "../../shared/test-utils.ts"; -import { http } from "./http.ts"; +import { + applyHttpConfiguration, + createHttpService, + createHttpRuntimeConfiguration, + http, +} from "./http.ts"; + +describe("HTTP runtime configuration", () => { + it("merges defaults and appends interceptor and XSRF policy", () => { + const configuration = createHttpRuntimeConfiguration(); + const interceptor = () => ({}); + + applyHttpConfiguration(configuration, { + defaults: { + headers: { + common: { Authorization: "Bearer token" }, + post: { "X-Mode": "configured" }, + }, + withCredentials: true, + }, + interceptors: [interceptor], + xsrfTrustedOrigins: ["https://api.example.com"], + }); + + expect(configuration.defaults.headers.common).toEqual({ + Accept: "application/json, text/plain, */*", + Authorization: "Bearer token", + }); + expect(configuration.defaults.headers.post).toEqual({ + "Content-Type": "application/json;charset=utf-8", + "X-Mode": "configured", + }); + expect(configuration.defaults.withCredentials).toBeTrue(); + expect(configuration.interceptors).toEqual([interceptor]); + expect(configuration.xsrfTrustedOrigins).toEqual([ + "https://api.example.com", + ]); + }); + + it("resolves a default serializer token while constructing the service", () => { + const configuration = createHttpRuntimeConfiguration(); + const serializer = () => "serialized"; + const injector = { + get: jasmine.createSpy("get").and.returnValue(serializer), + }; + + configuration.defaults.paramSerializer = "configuredSerializer"; + createHttpService(injector, {}, {}, {}, {}, configuration); + + expect(injector.get).toHaveBeenCalledOnceWith("configuredSerializer"); + expect(configuration.defaults.paramSerializer).toBe(serializer); + }); +}); describe("$http", function () { let $http, $injector, requests, response, $rootScope; + let httpConfigModule; + let securityId = 0; beforeEach(function () { window.angular = new Angular(); + securityId += 1; + httpConfigModule = window.angular.module( + `httpSecurityCompat${securityId}`, + [], + ); + httpConfigModule.config({ + $security: { + allowInsecureTransport: true, + }, + }); requests = []; - $injector = createInjector(["ng"]); + $injector = createInjector(["ng", `httpSecurityCompat${securityId}`]); $http = $injector.get("$http"); $rootScope = $injector.get("$rootScope"); }); @@ -29,6 +93,31 @@ describe("$http", function () { expect(result.then).toBeDefined(); }); + it("rejects invalid request configurations and URLs", async function () { + await expectAsync($http(null)).toBeRejectedWithError(/badreq/); + await expectAsync($http({ url: {} })).toBeRejectedWithError(/badreq/); + }); + + it("resolves a configured default parameter serializer through DI", async function () { + httpConfigModule.config({ + $http: { + defaults: { + paramSerializer: "$httpParamSerializer", + }, + }, + }); + const injector = createInjector(["ng", httpConfigModule.name]); + const configuredHttp = injector.get("$http"); + + const result = await configuredHttp.get("/mock/hello", { + params: { configured: true }, + }); + + expect(result.config.paramSerializer(result.config.params)).toBe( + "configured=true", + ); + }); + it("makes a fetch request to given URL", async function () { await $http({ method: "POST", @@ -337,6 +426,31 @@ describe("$http", function () { expect(response.config.withCredentials).toBe(true); }); + it("normalizes headers and transforms removed by request interceptors", async function () { + httpConfigModule.config({ + $http: { + interceptors: [ + () => ({ + request(config) { + config.headers = undefined; + config.transformRequest = undefined; + config.transformResponse = undefined; + + return config; + }, + }), + ], + }, + }); + const injector = createInjector(["ng", httpConfigModule.name]); + const configuredHttp = injector.get("$http"); + + const result = await configuredHttp.get("/mock/hello"); + + expect(result.data).toBe("Hello"); + expect(result.config.headers).toEqual({}); + }); + it("allows transforming requests with functions", async function () { let transformedData; @@ -847,20 +961,18 @@ describe("$http", function () { }); it("allows substituting param serializer through DI", async function () { - const injector = createInjector([ - "ng", - function ($provide) { - $provide.factory("mySpecialSerializer", function () { - return function (params) { - return Object.keys(params) - .map(function (k) { - return `${k}=${params[k]}lol`; - }) - .join("&"); - }; - }); - }, - ]); + window.angular + .module("specialSerializer", []) + .factory("mySpecialSerializer", function () { + return function (params) { + return Object.keys(params) + .map(function (k) { + return `${k}=${params[k]}lol`; + }) + .join("&"); + }; + }); + const injector = createInjector(["ng", "specialSerializer"]); await injector.invoke(async function ($http, $rootScope) { await $http({ @@ -991,16 +1103,102 @@ describe("$http", function () { expect(response.config.data).toBe("data"); }); - it("allows attaching interceptor factories", async function () { - const interceptorFactorySpy = jasmine.createSpy(); + it("supports shorthand methods when options are omitted", async function () { + const getResponse = await $http.get("/mock/hello"); + const postResponse = await $http.post("/mock/hello", "data"); - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(interceptorFactorySpy); - }, + expect(getResponse.config.method).toBe("GET"); + expect(postResponse.config.method).toBe("POST"); + }); + + it("uses request, configured, and internal caches", async function () { + const requestCache = new Map(); + + const first = await $http.get("/mock/hello", { cache: requestCache }); + const second = await $http.get("/mock/hello", { cache: requestCache }); + + expect(second.data).toBe(first.data); + expect(Array.isArray(requestCache.get("/mock/hello"))).toBeTrue(); + + const configuredCache = new Map(); + + $http.defaults.cache = configuredCache; + await $http.get("/mock/hello"); + expect(configuredCache.has("/mock/hello")).toBeTrue(); + + $http.defaults.cache = undefined; + await $http.get("/mock/hello", { cache: true }); + const internallyCached = await $http.get("/mock/hello", { cache: true }); + + expect(internallyCached.data).toBe("Hello"); + }); + + it("shares pending cached requests and removes failed responses", async function () { + const cache = new Map(); + const first = $http.get("/mock/hello", { cache }); + const second = $http.get("/mock/hello", { cache }); + + const responses = await Promise.all([first, second]); + + expect(responses.map((item) => item.data)).toEqual(["Hello", "Hello"]); + + await expectAsync($http.get("/mock/401", { cache })).toBeRejected(); + expect(cache.has("/mock/401")).toBeFalse(); + }); + + it("serves direct cache values and normalizes invalid cached statuses", async function () { + const valueCache = new Map([["/mock/value", "cached"]]); + const valueResponse = await $http.get("/mock/value", { + cache: valueCache, + }); + + expect(valueResponse.data).toBe("cached"); + expect(valueResponse.status).toBe(200); + + const statusCache = new Map([ + ["/mock/status", [-2, "failed", {}, "Invalid", "error"]], ]); + await expectAsync( + $http.get("/mock/status", { cache: statusCache }), + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 0, + }), + ); + }); + + it("rejects when a malformed pending cache entry cannot be resolved", async function () { + const pending = Promise.withResolvers(); + const cache = new Map([["/mock/malformed", pending.promise]]); + const request = $http.get("/mock/malformed", { cache }); + + pending.reject(undefined); + + await expectAsync(request).toBeRejected(); + }); + + it("adds XSRF cookies with default and request header names", async function () { + document.cookie = "XSRF-TOKEN=secret; path=/"; + + const defaultResponse = await $http.get("/mock/hello"); + const customResponse = await $http.get("/mock/hello", { + xsrfHeaderName: "X-CUSTOM-XSRF", + }); + + expect(defaultResponse.config.headers["X-XSRF-TOKEN"]).toBe("secret"); + expect(customResponse.config.headers["X-CUSTOM-XSRF"]).toBe("secret"); + + document.cookie = "XSRF-TOKEN=; Max-Age=0; path=/"; + }); + + it("allows attaching interceptor factories", async function () { + const interceptorFactorySpy = jasmine.createSpy(); + httpConfigModule.config({ + $http: { interceptors: [interceptorFactorySpy] }, + }); + const injector = createInjector(["ng", httpConfigModule.name]); + $http = injector.get("$http"); expect(interceptorFactorySpy).toHaveBeenCalled(); @@ -1008,13 +1206,12 @@ describe("$http", function () { it("uses DI to instantiate interceptors", async function () { const interceptorFactorySpy = jasmine.createSpy(); - - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(["$rootScope", interceptorFactorySpy]); + httpConfigModule.config({ + $http: { + interceptors: [["$rootScope", interceptorFactorySpy]], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); const $rootScope = injector.get("$rootScope"); @@ -1025,13 +1222,13 @@ describe("$http", function () { it("allows referencing existing interceptor factories", async function () { const interceptorFactorySpy = jasmine.createSpy().and.returnValue({}); - const injector = createInjector([ - "ng", - function ($provide, $httpProvider) { - $provide.factory("myInterceptor", interceptorFactorySpy); - $httpProvider.interceptors.push("myInterceptor"); - }, - ]); + window.angular + .module("existingInterceptor", []) + .factory("myInterceptor", interceptorFactorySpy) + .config({ + $http: { interceptors: ["myInterceptor"] }, + }); + const injector = createInjector(["ng", "existingInterceptor"]); $http = injector.get("$http"); @@ -1039,20 +1236,22 @@ describe("$http", function () { }); it("allows intercepting requests", async function () { - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(function () { - return { - request(config) { - config.params.intercepted = true; - - return config; - }, - }; - }); + httpConfigModule.config({ + $http: { + interceptors: [ + function () { + return { + request(config) { + config.params.intercepted = true; + + return config; + }, + }; + }, + ], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); $rootScope = injector.get("$rootScope"); @@ -1071,20 +1270,22 @@ describe("$http", function () { }); it("allows returning promises from request intercepts", async function () { - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(function () { - return { - request(config) { - config.params.intercepted = true; - - return Promise.resolve(config); - }, - }; - }); + httpConfigModule.config({ + $http: { + interceptors: [ + function () { + return { + request(config) { + config.params.intercepted = true; + + return Promise.resolve(config); + }, + }; + }, + ], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); $rootScope = injector.get("$rootScope"); @@ -1102,18 +1303,20 @@ describe("$http", function () { }); it("allows intercepting responses", async function () { - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(() => ({ - response(response) { - response.intercepted = true; - - return response; - }, - })); + httpConfigModule.config({ + $http: { + interceptors: [ + () => ({ + response(response) { + response.intercepted = true; + + return response; + }, + }), + ], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); $rootScope = injector.get("$rootScope"); @@ -1130,22 +1333,19 @@ describe("$http", function () { it("allows intercepting request errors", async function () { const requestErrorSpy = jasmine.createSpy(); - - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(() => ({ - request(config) { - throw "fail"; - }, - })); - $httpProvider.interceptors.push(() => { - return { - requestError: requestErrorSpy, - }; - }); + httpConfigModule.config({ + $http: { + interceptors: [ + () => ({ + request() { + throw "fail"; + }, + }), + () => ({ requestError: requestErrorSpy }), + ], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); $rootScope = injector.get("$rootScope"); @@ -1161,20 +1361,19 @@ describe("$http", function () { it("allows intercepting response errors", async function () { const responseErrorSpy = jasmine.createSpy(); - - const injector = createInjector([ - "ng", - function ($httpProvider) { - $httpProvider.interceptors.push(() => ({ - responseError: responseErrorSpy, - })); - $httpProvider.interceptors.push(() => ({ - response() { - throw "fail"; - }, - })); + httpConfigModule.config({ + $http: { + interceptors: [ + () => ({ responseError: responseErrorSpy }), + () => ({ + response() { + throw "fail"; + }, + }), + ], }, - ]); + }); + const injector = createInjector(["ng", httpConfigModule.name]); $http = injector.get("$http"); $rootScope = injector.get("$rootScope"); @@ -1287,6 +1486,331 @@ describe("$http", function () { await wait(); expect(loadSpy).toHaveBeenCalled(); }); + + it("calls object event listeners", async function () { + const listener = { + handleEvent: jasmine.createSpy("handleEvent"), + }; + + await $http.get("/mock/hello", { + eventHandlers: { load: listener }, + }); + + expect(listener.handleEvent).toHaveBeenCalledWith(jasmine.any(Event)); + }); +}); + +function getRequestHeader(request, headerName) { + const headers = request.headers; + + if (headers instanceof Headers) { + return headers.get(headerName); + } + + return headers?.[headerName]; +} + +describe("$http security policy", () => { + let fetchEnv; + + let $http; + + let requestDecision; + + let credentials; + + beforeEach(() => { + fetchEnv = installFakeFetch(); + + requestDecision = { + type: "allow", + }; + + credentials = undefined; + + window.angular = new Angular(); + const module = window.angular.module("defaultSecurityPolicyModule", []); + + module.value("$security", { + check: () => Promise.resolve(requestDecision), + attachRequestAuth: () => Promise.resolve(credentials), + }); + + const $injector = createInjector(["ng", "defaultSecurityPolicyModule"]); + $http = $injector.get("$http"); + }); + + afterEach(() => { + fetchEnv.restore(); + }); + + it("attaches jwt credentials when request policy allows", async () => { + credentials = { + credential: { + kind: "jwt", + value: "abc123", + }, + }; + + const result = $http.get("/mock/hello"); + await wait(); + + expect(fetchEnv.requests.length).toBe(1); + expect(getRequestHeader(fetchEnv.requests[0], "Authorization")).toBe( + "Bearer abc123", + ); + + fetchEnv.requests[0].respond(200, "OK", '{"ok":true}'); + + const response = await result; + expect(response.status).toBe(200); + expect(response.config.headers.Authorization).toBe("Bearer abc123"); + }); + + it("denies request policy decisions before fetch", async () => { + requestDecision = { + type: "deny", + reason: "blocked", + status: 403, + }; + + let error; + + try { + await $http.get("/mock/hello"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.status).toBe(403); + expect(error.statusText).toBe("blocked"); + expect(fetchEnv.requests.length).toBe(0); + }); + + it("uses the default deny response shape when policy omits status details", async () => { + requestDecision = { + type: "deny", + }; + + let error; + + try { + await $http.get("/mock/hello"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.status).toBe(403); + expect(error.statusText).toBe("Navigation denied by security policy"); + expect(fetchEnv.requests.length).toBe(0); + }); + + it("redirects request policy decisions before fetch", async () => { + requestDecision = { + type: "redirect", + reason: "login required", + status: 302, + target: "/login", + }; + + let error; + + try { + await $http.get("/mock/hello"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.status).toBe(302); + expect(error.statusText).toBe("login required"); + expect(fetchEnv.requests.length).toBe(0); + }); + + it("uses the default redirect response shape when policy omits status details", async () => { + requestDecision = { + type: "redirect", + target: "/login", + }; + + let error; + + try { + await $http.get("/mock/hello"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.status).toBe(302); + expect(error.statusText).toBe("Request redirected by security policy"); + expect(fetchEnv.requests.length).toBe(0); + }); + + it("attaches custom header credentials when request policy allows", async () => { + credentials = { + headers: { + "X-Auth-Token": "custom-token", + }, + withCredentials: true, + }; + + const result = $http.get("/mock/hello"); + + await wait(); + + expect(fetchEnv.requests.length).toBe(1); + expect(getRequestHeader(fetchEnv.requests[0], "X-Auth-Token")).toBe( + "custom-token", + ); + expect(fetchEnv.requests[0].init.credentials).toBe("include"); + + fetchEnv.requests[0].respond(200, "OK", "{}"); + + const response = await result; + + expect(response.config.headers["X-Auth-Token"]).toBe("custom-token"); + expect(response.config.withCredentials).toBeTrue(); + }); + + it("attaches basic credentials when request policy allows", async () => { + credentials = { + credential: { + kind: "basic", + value: "encoded", + }, + }; + + const result = $http.get("/mock/hello"); + + await wait(); + + expect(fetchEnv.requests.length).toBe(1); + expect(getRequestHeader(fetchEnv.requests[0], "Authorization")).toBe( + "Basic encoded", + ); + + fetchEnv.requests[0].respond(200, "OK", "{}"); + + const response = await result; + + expect(response.config.headers.Authorization).toBe("Basic encoded"); + }); +}); + +describe("$http security policy via app.config", () => { + let fetchEnv; + let injectorId = 0; + + afterEach(() => { + fetchEnv?.restore(); + }); + + function createConfiguredHttp(config) { + injectorId += 1; + const moduleName = `httpSecurityConfiguredModule${injectorId}`; + + window.angular = new Angular(); + const module = window.angular.module(moduleName, []); + module.config({ $security: config }); + + fetchEnv = installFakeFetch(); + + return createInjector(["ng", moduleName]).get("$http"); + } + + it("denies insecure requests by default when configured centrally", async () => { + const $http = createConfiguredHttp({ + defaultDecision: "allow", + allowInsecureTransport: false, + }); + + let error; + + try { + await $http({ + method: "GET", + url: "http://example.invalid/resource", + withCredentials: true, + }); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.status).toBe(401); + expect(error.statusText).toBe("insecure transport blocked"); + expect(fetchEnv.requests.length).toBe(0); + }); + + it("honors allowInsecureTransport:true from app.config()", async () => { + const $http = createConfiguredHttp({ + defaultDecision: "allow", + allowInsecureTransport: true, + }); + + const resultPromise = $http({ + method: "GET", + url: "http://example.invalid/resource", + withCredentials: true, + }); + + await wait(); + expect(fetchEnv.requests.length).toBe(1); + fetchEnv.requests[0].respond(200, "OK", "{}"); + + const result = await resultPromise; + + expect(result.status).toBe(200); + expect(result.data).toEqual({}); + }); + + it("attaches configured jwt credentials through app.config()", async () => { + const $http = createConfiguredHttp({ + defaultDecision: "deny", + branches: ["jwt"], + credentials: { + jwt: "configured-token", + }, + }); + + const resultPromise = $http.get("/mock/hello"); + + await wait(); + expect(fetchEnv.requests.length).toBe(1); + expect(getRequestHeader(fetchEnv.requests[0], "Authorization")).toBe( + "Bearer configured-token", + ); + fetchEnv.requests[0].respond(200, "OK", "{}"); + + const result = await resultPromise; + + expect(result.status).toBe(200); + }); + + it("enables cookie session requests through app.config()", async () => { + const $http = createConfiguredHttp({ + defaultDecision: "deny", + branches: ["cookieSession"], + credentials: { + cookieSession: true, + }, + }); + + const resultPromise = $http.get("/mock/hello"); + + await wait(); + expect(fetchEnv.requests.length).toBe(1); + expect(fetchEnv.requests[0].init.credentials).toBe("include"); + expect(getRequestHeader(fetchEnv.requests[0], "Cookie")).toBeNull(); + fetchEnv.requests[0].respond(200, "OK", "{}"); + + const result = await resultPromise; + + expect(result.status).toBe(200); + }); }); describe("http", () => { @@ -1805,14 +2329,11 @@ describe("http", () => { // let $rootScope; // let $sce; -// beforeEach( -// module(($sceDelegateProvider) => { -// // Setup a special trusted url that we can use in testing JSONP requests -// $sceDelegateProvider.trustedResourceUrlList([ -// "http://special.trusted.resource.com/**", -// ]); -// }), -// ); +// app.config({ +// $sceDelegate: { +// trustedResourceUrlList: ["http://special.trusted.resource.com/**"], +// }, +// }); // beforeEach(inject([ // "$httpBackend", diff --git a/src/services/http/http.ts b/src/services/http/http.ts index 5719d5fc8..2f12b9936 100644 --- a/src/services/http/http.ts +++ b/src/services/http/http.ts @@ -1,10 +1,4 @@ -import { - _cookie, - _httpParamSerializer, - _injector, - _sce, - _stream, -} from "../../injection-tokens.ts"; +import { _httpParamSerializer } from "../../injection-tokens.ts"; import { trimEmptyHash, urlIsAllowedOriginFactory, @@ -42,6 +36,11 @@ import { uppercase, assertDefined, } from "../../shared/utils.ts"; +import type { + RequestPolicyContext, + SecurityPolicy, + SecurityRequestCredentials, +} from "../security/security.ts"; const APPLICATION_JSON = "application/json"; @@ -86,6 +85,25 @@ export interface HttpRequestConfigHeaders { patch?: HttpHeaderType; } +/** @internal */ +export function mergeHttpHeaderDefaults( + current: HttpRequestConfigHeaders | undefined, + next: HttpRequestConfigHeaders, +): HttpRequestConfigHeaders { + const merged: HttpRequestConfigHeaders = { ...(current ?? {}) }; + + for (const [key, value] of Object.entries(next)) { + const existing = merged[key]; + + merged[key] = + isObject(existing) && !isFunction(existing) && isObject(value) + ? Object.assign({}, existing, value) + : value; + } + + return merged; +} + // See the jsdoc for transformData() at https://github.com/angular/angular.ts/blob/master/src/ng/http.js#L228 /** Transforms request data before it is sent. */ export type HttpRequestTransformer = ( @@ -107,7 +125,7 @@ export type HttpHeaderValue = | boolean | null | undefined - | ((config: RequestConfig) => unknown); + | ((config: HttpRequestConfig) => unknown); export type HttpHeaderType = Record; @@ -118,16 +136,16 @@ export interface HttpCacheLike { } /** - * Default request settings exposed through `$httpProvider.defaults`. + * Default request settings configured through `app.config({ $http })` and + * exposed at runtime through `$http.defaults`. * - * Not every `RequestShortcutConfig` field is supported here; this shape only includes the + * Not every `HttpRequestOptions` field is supported here; this shape only includes the * fields that the runtime reads from provider-level defaults. * * https://docs.angularjs.org/api/ng/service/$http#defaults * https://docs.angularjs.org/api/ng/service/$http#usage - * https://docs.angularjs.org/api/ng/provider/$httpProvider The properties section */ -export interface HttpProviderDefaults { +export interface HttpDefaults { /** Cache used for cacheable requests. `true` enables the default cache. */ cache?: boolean | HttpCacheLike; /** Request body transform pipeline. */ @@ -165,7 +183,7 @@ export type HttpResponseType = * Request options shared by the `$http` shortcut methods. * See http://docs.angularjs.org/api/ng/service/$http#usage */ -export interface RequestShortcutConfig extends HttpProviderDefaults { +export interface HttpRequestOptions extends HttpDefaults { /** Query parameters appended to the request URL. */ params?: HttpParams; /** Request body. Shorthand methods with explicit data set this automatically. */ @@ -180,7 +198,7 @@ export interface RequestShortcutConfig extends HttpProviderDefaults { * Full request configuration accepted by `$http(...)`. * See http://docs.angularjs.org/api/ng/service/$http#usage */ -export interface RequestConfig extends RequestShortcutConfig { +export interface HttpRequestConfig extends HttpRequestOptions { /** HTTP verb to use for the request. */ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; /** Request URL. Query parameters from `params` are appended to this URL. */ @@ -193,13 +211,13 @@ export interface RequestConfig extends RequestShortcutConfig { >; } -/** HTTP method accepted by {@link RequestConfig.method}. */ -export type HttpMethod = RequestConfig["method"]; +/** HTTP method accepted by {@link HttpRequestConfig.method}. */ +export type HttpMethod = HttpRequestConfig["method"]; /** Final transport status reported by transport completion handlers. */ export type HttpResponseStatus = "complete" | "error" | "timeout" | "abort"; -/** Response object used to resolve {@link HttpPromise}. */ +/** Response object returned by `$http` requests. */ export interface HttpResponse { /** Parsed response body. */ data: T; @@ -208,7 +226,7 @@ export interface HttpResponse { /** Lazy response header reader. */ headers: HttpHeadersGetter; /** Request configuration that produced this response. */ - config: RequestConfig; + config: HttpRequestConfig; /** Native status text such as `OK` or `Not Found`. */ statusText: string; /** Transport completion status. Useful for distinguishing timeout, abort, and network errors. */ @@ -223,7 +241,7 @@ export class HttpError extends Error implements HttpResponse { headers: HttpHeadersGetter; - config: RequestConfig; + config: HttpRequestConfig; statusText: string; @@ -248,48 +266,45 @@ export class HttpError extends Error implements HttpResponse { } } -/** Promise returned by `$http` requests. */ -export type HttpPromise = Promise>; - /** * Runtime surface of the `$http` service. * - * Call the service directly with a full {@link RequestConfig}, or use a + * Call the service directly with a full {@link HttpRequestConfig}, or use a * shorthand method for common HTTP verbs. All methods return an - * {@link HttpPromise} that resolves with {@link HttpResponse} for successful - * 2xx responses and rejects with {@link HttpError} for errors. + * `Promise>` that resolves for successful 2xx responses and + * rejects with {@link HttpError} for errors. */ export interface HttpService { /** Send a request using the full configuration object. */ - (config: RequestConfig): HttpPromise; + (config: HttpRequestConfig): Promise>; /** Send a `GET` request. */ - get(url: string, config?: RequestShortcutConfig): HttpPromise; + get(url: string, config?: HttpRequestOptions): Promise>; /** Send a `DELETE` request. */ - delete(url: string, config?: RequestShortcutConfig): HttpPromise; + delete(url: string, config?: HttpRequestOptions): Promise>; /** Send a `HEAD` request. */ - head(url: string, config?: RequestShortcutConfig): HttpPromise; + head(url: string, config?: HttpRequestOptions): Promise>; /** Send a `POST` request with a request body. */ post( url: string, data: unknown, - config?: RequestShortcutConfig, - ): HttpPromise; + config?: HttpRequestOptions, + ): Promise>; /** Send a `PUT` request with a request body. */ put( url: string, data: unknown, - config?: RequestShortcutConfig, - ): HttpPromise; + config?: HttpRequestOptions, + ): Promise>; /** Send a `PATCH` request with a request body. */ patch( url: string, data: unknown, - config?: RequestShortcutConfig, - ): HttpPromise; - /** Runtime defaults shared with `$httpProvider.defaults`. */ - defaults: HttpProviderDefaults; + config?: HttpRequestOptions, + ): Promise>; + /** Live runtime defaults initialized from app-level `$http` configuration. */ + defaults: HttpDefaults; /** Requests currently in flight. */ - pendingRequests: RequestConfig[]; + pendingRequests: HttpRequestConfig[]; } /** Query parameter bag that can be serialized into a URL query string. */ @@ -299,16 +314,20 @@ export type HttpParams = Record; export type HttpParamSerializer = (params?: HttpParams) => string; /** - * Interceptor hooks registered through `$httpProvider.interceptors`. + * Interceptor hooks registered through app-level `$http` configuration. * * Request hooks run in registration order. Response hooks run in reverse order. * Each hook may return the value directly or return a promise. */ export interface HttpInterceptor { /** Inspect or replace the request config before transport. */ - request?(config: RequestConfig): RequestConfig | Promise; + request?( + config: HttpRequestConfig, + ): HttpRequestConfig | Promise; /** Recover from a previous request interceptor failure. */ - requestError?(rejection: unknown): RequestConfig | Promise; + requestError?( + rejection: unknown, + ): HttpRequestConfig | Promise; /** Inspect or replace a successful response. */ response?( response: HttpResponse, @@ -319,27 +338,27 @@ export interface HttpInterceptor { ): Promise> | HttpResponse; } -/** Factory registered with `$httpProvider.interceptors`. */ +/** Factory registered in `HttpConfig.interceptors`. */ export type HttpInterceptorFactory = () => HttpInterceptor; type HttpInterceptorHook = (value: unknown) => unknown; -/** Provider configuration surface available as `$httpParamSerializerProvider`. */ -export interface HttpParamSerializerProvider { - /** Creates the runtime query-parameter serializer. */ - $get?: () => HttpParamSerializer; -} - -/** Provider configuration surface available as `$httpProvider`. */ -export interface HttpProvider { - /** Default values applied to all `$http` requests unless a request overrides them. */ - defaults: HttpProviderDefaults; - /** Interceptor factories applied to requests and responses. */ - interceptors: (string | ng.Injectable)[]; - /** Origins trusted to receive the XSRF token. */ - xsrfTrustedOrigins: string[]; - /** @internal */ - $get?: unknown; +/** + * Declarative configuration accepted by `NgModule.config({ $http: ... })`. + */ +export interface HttpConfig { + /** + * Default values merged into the live `$http.defaults` object. + */ + defaults?: HttpDefaults; + /** + * Interceptor factories applied to requests and responses. + */ + interceptors?: (string | ng.Injectable)[]; + /** + * Origins trusted to receive the configured XSRF token. + */ + xsrfTrustedOrigins?: string[]; } /** @@ -391,53 +410,48 @@ function serializeValue( * * `{'foo': {'bar':'baz'}}` results in `foo=%7B%22bar%22%3A%22baz%22%7D` (stringified and encoded representation of an object) * * Note that serializer will sort the request parameters alphabetically. + * + * @internal */ -export function HttpParamSerializerProvider( - this: HttpParamSerializerProvider, -): void { - /** - * Returns the runtime query-parameter serializer. - */ - this.$get = () => { - return (params: Record | null | undefined) => { - if (!params) return ""; - const parts: string[] = []; - - keys(params) - .sort() - .forEach((key) => { - const value: unknown = params[key]; - - if (isNullOrUndefined(value) || isFunction(value)) return; - - if (isArray(value)) { - value.forEach((v) => { - if (isNullOrUndefined(v) || isFunction(v)) return; - - const serializedValue = serializeValue( - v as string | number | boolean | Record | Date, - ); - - parts.push( - `${encodeUriQuery(key)}=${encodeUriQuery(String(serializedValue))}`, - ); - }); - } else { - const sanitizedValue = value as - | string - | number - | boolean - | Record - | Date; +export function createHttpParamSerializer(): HttpParamSerializer { + return (params: Record | null | undefined) => { + if (!params) return ""; + const parts: string[] = []; + + keys(params) + .sort() + .forEach((key) => { + const value: unknown = params[key]; + + if (isNullOrUndefined(value) || isFunction(value)) return; + + if (isArray(value)) { + value.forEach((v) => { + if (isNullOrUndefined(v) || isFunction(v)) return; + + const serializedValue = serializeValue( + v as string | number | boolean | Record | Date, + ); parts.push( - `${encodeUriQuery(key)}=${encodeUriQuery(String(serializeValue(sanitizedValue)))}`, + `${encodeUriQuery(key)}=${encodeUriQuery(String(serializedValue))}`, ); - } - }); + }); + } else { + const sanitizedValue = value as + | string + | number + | boolean + | Record + | Date; + + parts.push( + `${encodeUriQuery(key)}=${encodeUriQuery(String(serializeValue(sanitizedValue)))}`, + ); + } + }); - return parts.join("&"); - }; + return parts.join("&"); }; } @@ -491,7 +505,7 @@ function isJsonLike(str: string): boolean { * @returns A normalized header map keyed by lowercase header name. */ function parseHeaders( - headers: string | object, + headers: string | object | null, ): Partial> { const parsed: Partial> = nullObject(); @@ -647,15 +661,22 @@ function deProxyHttpPayload( return output; } -/** Configures the default behavior of the `$http` service. */ -export function HttpProvider(this: HttpProvider): void { +/** @internal */ +export interface HttpRuntimeConfiguration { + defaults: HttpDefaults; + interceptors: (string | ng.Injectable)[]; + xsrfTrustedOrigins: string[]; +} + +/** @internal */ +export function createHttpRuntimeConfiguration(): HttpRuntimeConfiguration { /** * Default values applied to all `$http` requests unless a request overrides them. * * This includes cache behavior, default headers, request/response transforms, XSRF names, * credentials defaults, and parameter serialization. */ - const defaults: HttpProviderDefaults = (this.defaults = { + const defaults: HttpDefaults = { // transform incoming response data transformResponse: [defaultHttpResponseTransform], // transform outgoing request data @@ -681,7 +702,7 @@ export function HttpProvider(this: HttpProvider): void { xsrfCookieName: "XSRF-TOKEN", xsrfHeaderName: "X-XSRF-TOKEN", paramSerializer: _httpParamSerializer, - }); + }; /** * Array containing service factories for all synchronous or asynchronous `$http` @@ -692,7 +713,7 @@ export function HttpProvider(this: HttpProvider): void { * * See the `$http` service documentation for detailed interceptor behavior. */ - this.interceptors = [] as (string | ng.Injectable)[]; + const interceptors = [] as (string | ng.Injectable)[]; /** * Array containing URLs whose origins are trusted to receive the XSRF token. See the @@ -715,12 +736,14 @@ export function HttpProvider(this: HttpProvider): void { * * ```js * // App served from `https://example.com/`. - * angular. - * module('xsrfTrustedOriginsExample', []). - * config(['$httpProvider', function($httpProvider) { - * $httpProvider.xsrfTrustedOrigins.push('https://api.example.com'); - * }]). - * run(['$http', function($http) { + * angular + * .module('xsrfTrustedOriginsExample', []) + * .config({ + * $http: { + * xsrfTrustedOrigins: ['https://api.example.com'], + * }, + * }) + * .run(['$http', function($http) { * // The XSRF token will be sent. * $http.get('https://api.example.com/preferences').then(...); * @@ -730,567 +753,662 @@ export function HttpProvider(this: HttpProvider): void { * ``` * */ - this.xsrfTrustedOrigins = [] as string[]; - - this.$get = [ - _injector, - _sce, - _cookie, - _stream, - /** Creates the runtime `$http` service. */ - ( - $injector: ng.InjectorService, - $sce: ng.SceService, - $cookie: ng.CookieService, - $stream: ng.StreamService, - ) => { - const defaultCache = new Map(); - - /** - * Resolves the configured default param serializer to a callable function. - */ - defaults.paramSerializer = isString(defaults.paramSerializer) - ? ($injector.get(defaults.paramSerializer) as HttpParamSerializer) - : defaults.paramSerializer; - - /** - * Interceptors stored in reverse order. Inner interceptors before outer interceptors. - * The reversal lets request interceptors wrap the server request in the expected order. - */ - const reversedInterceptors: HttpInterceptor[] = []; - - this.interceptors.forEach( - ( - interceptorFactory: string | ng.Injectable, - ) => { - const interceptor: unknown = isString(interceptorFactory) - ? $injector.get(interceptorFactory) - : $injector.invoke(interceptorFactory); - - reversedInterceptors.unshift(interceptor as HttpInterceptor); - }, - ); + const xsrfTrustedOrigins: string[] = []; + + return { defaults, interceptors, xsrfTrustedOrigins }; +} + +/** @internal */ +export function applyHttpConfiguration( + configuration: HttpRuntimeConfiguration, + config: HttpConfig, +): void { + const configuredDefaults = config.defaults; + + if (configuredDefaults !== undefined) { + const headers = configuredDefaults.headers; + const currentHeaders = configuration.defaults.headers; - /** - * Creates the origin check used for XSRF header inclusion. - */ - const urlIsAllowedOrigin = urlIsAllowedOriginFactory( - this.xsrfTrustedOrigins, + Object.assign(configuration.defaults, configuredDefaults); + + if (headers !== undefined) { + configuration.defaults.headers = mergeHttpHeaderDefaults( + currentHeaders, + headers, ); + } + } - /** - * Issues an HTTP request using the provider defaults and configured interceptors. - */ - const $http = async function ( - requestConfig: RequestConfig, - ): HttpPromise { - if (!isObject(requestConfig)) { - throw $httpError( - "badreq", - "Http request configuration must be an object. Received: {0}", - requestConfig, - ); - } + if (config.interceptors !== undefined) { + configuration.interceptors.push(...config.interceptors); + } - if (!isString($sce.valueOf(requestConfig.url))) { - throw $httpError( - "badreq", - "Http request configuration url must be a string or a $sce trusted object. Received: {0}", - requestConfig.url, - ); - } + if (config.xsrfTrustedOrigins !== undefined) { + configuration.xsrfTrustedOrigins.push(...config.xsrfTrustedOrigins); + } +} - const hasCustomResponseTransform = hasOwn( - requestConfig, - "transformResponse", - ); +/** @internal */ +export function createHttpService( + $injector: ng.InjectorService, + $sce: ng.SceService, + $cookie: ng.CookieService, + $security: SecurityPolicy, + $stream: ng.StreamService, + configuration: HttpRuntimeConfiguration, +): HttpService { + const { defaults, interceptors, xsrfTrustedOrigins } = configuration; + const defaultCache = new Map(); - const config = extend( - { - method: "get", - transformRequest: defaults.transformRequest, - transformResponse: defaults.transformResponse, - paramSerializer: defaults.paramSerializer, - }, - requestConfig, - ) as unknown as RequestConfig; - - if (config.responseType === "stream" && !hasCustomResponseTransform) { - config.transformResponse = []; - } + /** + * Resolves the configured default param serializer to a callable function. + */ + if (isString(defaults.paramSerializer)) { + defaults.paramSerializer = $injector.get( + defaults.paramSerializer, + ); + } - config.headers = mergeHeaders(requestConfig); - config.method = uppercase(config.method) as HttpMethod; - config.paramSerializer = isString(config.paramSerializer) - ? ($injector.get(config.paramSerializer) as HttpParamSerializer) - : config.paramSerializer; + /** + * Interceptors stored in reverse order. Inner interceptors before outer interceptors. + * The reversal lets request interceptors wrap the server request in the expected order. + */ + const reversedInterceptors: HttpInterceptor[] = []; - const requestInterceptors: (HttpInterceptorHook | undefined)[] = []; + interceptors.forEach( + (interceptorFactory: string | ng.Injectable) => { + const interceptor: unknown = isString(interceptorFactory) + ? $injector.get(interceptorFactory) + : $injector.invoke(interceptorFactory); - const responseInterceptors: (HttpInterceptorHook | undefined)[] = []; + reversedInterceptors.unshift(interceptor as HttpInterceptor); + }, + ); - let promise: Promise = Promise.resolve(config); + /** + * Creates the origin check used for XSRF header inclusion. + */ + const urlIsAllowedOrigin = urlIsAllowedOriginFactory(xsrfTrustedOrigins); - // apply interceptors - reversedInterceptors.forEach((interceptor) => { - if (interceptor.request || interceptor.requestError) { - requestInterceptors.unshift( - interceptor.request?.bind(interceptor) as - | HttpInterceptorHook - | undefined, - interceptor.requestError?.bind(interceptor) as - | HttpInterceptorHook - | undefined, - ); - } + /** + * Issues an HTTP request using the provider defaults and configured interceptors. + */ + const $http = async function ( + requestConfig: HttpRequestConfig, + ): Promise> { + if (!isObject(requestConfig)) { + throw $httpError( + "badreq", + "Http request configuration must be an object. Received: {0}", + requestConfig, + ); + } - if (interceptor.response || interceptor.responseError) { - responseInterceptors.push( - interceptor.response?.bind(interceptor) as - | HttpInterceptorHook - | undefined, - interceptor.responseError?.bind(interceptor) as - | HttpInterceptorHook - | undefined, - ); - } - }); + if (!isString($sce.valueOf(requestConfig.url))) { + throw $httpError( + "badreq", + "Http request configuration url must be a string or a $sce trusted object. Received: {0}", + requestConfig.url, + ); + } - promise = chainInterceptors(promise, requestInterceptors); - promise = promise.then(async (value) => - serverRequest(value as RequestConfig), + const hasCustomResponseTransform = hasOwn( + requestConfig, + "transformResponse", + ); + + const config = extend( + { + method: "get", + transformRequest: defaults.transformRequest, + transformResponse: defaults.transformResponse, + paramSerializer: defaults.paramSerializer, + }, + requestConfig, + ) as unknown as HttpRequestConfig; + + if (config.responseType === "stream" && !hasCustomResponseTransform) { + config.transformResponse = []; + } + + config.headers = mergeHeaders(requestConfig); + config.method = uppercase(config.method) as HttpMethod; + config.paramSerializer = isString(config.paramSerializer) + ? $injector.get(config.paramSerializer) + : config.paramSerializer; + + const requestInterceptors: (HttpInterceptorHook | undefined)[] = []; + + const responseInterceptors: (HttpInterceptorHook | undefined)[] = []; + + let promise: Promise = Promise.resolve(config); + + // apply interceptors + reversedInterceptors.forEach((interceptor) => { + if (interceptor.request || interceptor.requestError) { + requestInterceptors.unshift( + interceptor.request?.bind(interceptor) as + | HttpInterceptorHook + | undefined, + interceptor.requestError?.bind(interceptor) as + | HttpInterceptorHook + | undefined, ); - promise = chainInterceptors(promise, responseInterceptors); + } + + if (interceptor.response || interceptor.responseError) { + responseInterceptors.push( + interceptor.response?.bind(interceptor) as + | HttpInterceptorHook + | undefined, + interceptor.responseError?.bind(interceptor) as + | HttpInterceptorHook + | undefined, + ); + } + }); - return promise as HttpPromise; + promise = chainInterceptors(promise, requestInterceptors); + promise = promise.then(async (value) => + serverRequest(value as HttpRequestConfig), + ); + promise = chainInterceptors(promise, responseInterceptors); - /** Applies a list of interceptor success/error pairs to a promise chain. */ - async function chainInterceptors( - promiseParam: Promise, - interceptors: (HttpInterceptorHook | undefined)[], - ): Promise { - for (let i = 0, ii = interceptors.length; i < ii; ) { - const thenFn = interceptors[i++]; + return promise as Promise>; - const rejectFn = interceptors[i++]; + /** Applies a list of interceptor success/error pairs to a promise chain. */ + async function chainInterceptors( + promiseParam: Promise, + interceptors: (HttpInterceptorHook | undefined)[], + ): Promise { + for (let i = 0, ii = interceptors.length; i < ii; ) { + const thenFn = interceptors[i++]; - promiseParam = promiseParam.then(thenFn, rejectFn); - } + const rejectFn = interceptors[i++]; - interceptors.length = 0; + promiseParam = promiseParam.then(thenFn, rejectFn); + } - return promiseParam; - } + interceptors.length = 0; - /** Resolves any header factory functions against the current request configuration. */ - function executeHeaderFns( - headers: HttpHeaderType, - configParam: RequestConfig, - ): Record { - const processedHeaders: Record = {}; - - entries(headers).forEach(([header, headerFn]) => { - if (isFunction(headerFn)) { - const headerContent: unknown = headerFn(configParam); - - if (!isNullOrUndefined(headerContent)) { - processedHeaders[header] = stringify(headerContent); - } - } else if (!isNullOrUndefined(headerFn)) { - processedHeaders[header] = String(headerFn); - } - }); + return promiseParam; + } - return processedHeaders; - } + /** Resolves any header factory functions against the current request configuration. */ + function executeHeaderFns( + headers: HttpHeaderType, + configParam: HttpRequestConfig, + ): Record { + const processedHeaders: Record = {}; - /** Merges provider defaults with request-specific headers for a single request. */ - function mergeHeaders( - configParam: RequestConfig, - ): Record { - let defHeaders = defaults.headers ?? {}; - - const reqHeaders = extend( - {}, - configParam.headers ?? {}, - ) as HttpHeaderType; - - defHeaders = extend( - {}, - defHeaders.common ?? {}, - defHeaders[lowercase(configParam.method)] ?? {}, - ) as HttpRequestConfigHeaders; - - keys(defHeaders).forEach((defHeaderName) => { - const lowercaseDefHeaderName = lowercase(defHeaderName); - - const hasMatchingHeader = keys(reqHeaders).some((reqHeaderName) => { - return lowercase(reqHeaderName) === lowercaseDefHeaderName; - }); - - if (!hasMatchingHeader) { - reqHeaders[defHeaderName] = ( - defHeaders as Record - )[defHeaderName]; - } - }); + entries(headers).forEach(([header, headerFn]) => { + if (isFunction(headerFn)) { + const headerContent: unknown = headerFn(configParam); - // execute if header value is a function for merged headers - return executeHeaderFns(reqHeaders, shallowCopy(configParam)); + if (!isNullOrUndefined(headerContent)) { + processedHeaders[header] = stringify(headerContent); + } + } else if (!isNullOrUndefined(headerFn)) { + processedHeaders[header] = String(headerFn); } + }); - /** Executes the request pipeline and attaches response transforms. */ - async function serverRequest( - configParam: RequestConfig, - ): Promise { - const headers = configParam.headers ?? {}; - - configParam.headers = headers; - - const reqData: unknown = transformData( - configParam.data, - headersGetter(headers), - undefined, - (configParam.transformRequest as - | (( - data: unknown, - headers: HttpHeadersGetter, - status?: number, - ) => unknown) - | (( - data: unknown, - headers: HttpHeadersGetter, - status?: number, - ) => unknown)[] - | undefined) ?? [], - ); + return processedHeaders; + } - // strip content-type if data is undefined - if (isUndefined(reqData)) { - keys(headers).forEach((header) => { - if (lowercase(header) === "content-type") { - deleteProperty(headers, header); - } - }); - } + /** Merges provider defaults with request-specific headers for a single request. */ + function mergeHeaders( + configParam: HttpRequestConfig, + ): Record { + let defHeaders = defaults.headers ?? {}; - const providerDefaults = defaults; + const reqHeaders = extend( + {}, + configParam.headers ?? {}, + ) as HttpHeaderType; - if ( - isUndefined(configParam.withCredentials) && - !isUndefined(providerDefaults.withCredentials) - ) { - configParam.withCredentials = providerDefaults.withCredentials; - } + defHeaders = extend( + {}, + defHeaders.common ?? {}, + defHeaders[lowercase(configParam.method)] ?? {}, + ) as HttpRequestConfigHeaders; - // send request - return sendReq(configParam, reqData).then( - transformResponse, - transformResponse, - ); - } + keys(defHeaders).forEach((defHeaderName) => { + const lowercaseDefHeaderName = lowercase(defHeaderName); - /** Applies response transforms and rejects responses outside the success range. */ - async function transformResponse(response: unknown) { - const httpResponse = response as HttpResponse; - - // make a copy since the response must be cacheable - const resp = extend({}, httpResponse) as HttpResponse; - - resp.data = transformData( - httpResponse.data, - httpResponse.headers, - httpResponse.status, - (config.transformResponse as - | (( - data: unknown, - headers: HttpHeadersGetter, - status?: number, - ) => unknown) - | (( - data: unknown, - headers: HttpHeadersGetter, - status?: number, - ) => unknown)[] - | undefined) ?? [], - ); + const hasMatchingHeader = keys(reqHeaders).some((reqHeaderName) => { + return lowercase(reqHeaderName) === lowercaseDefHeaderName; + }); - return isSuccess(httpResponse.status) - ? resp - : Promise.reject(new HttpError(resp)); + if (!hasMatchingHeader) { + reqHeaders[defHeaderName] = ( + defHeaders as Record + )[defHeaderName]; } - } as HttpService & { - pendingRequests: RequestConfig[]; - defaults: HttpProviderDefaults; - [key: string]: unknown; + }); + + // execute if header value is a function for merged headers + return executeHeaderFns(reqHeaders, shallowCopy(configParam)); + } + + /** Executes the request pipeline and attaches response transforms. */ + async function serverRequest( + configParam: HttpRequestConfig, + ): Promise { + const headers = configParam.headers ?? {}; + const securityHeaders = headers as Record; + + configParam.headers = headers; + + const reqData: unknown = transformData( + configParam.data, + headersGetter(headers), + undefined, + (configParam.transformRequest as + | (( + data: unknown, + headers: HttpHeadersGetter, + status?: number, + ) => unknown) + | (( + data: unknown, + headers: HttpHeadersGetter, + status?: number, + ) => unknown)[] + | undefined) ?? [], + ); + + // strip content-type if data is undefined + if (isUndefined(reqData)) { + keys(headers).forEach((header) => { + if (lowercase(header) === "content-type") { + deleteProperty(headers, header); + } + }); + } + + const providerDefaults = defaults; + + if ( + isUndefined(configParam.withCredentials) && + !isUndefined(providerDefaults.withCredentials) + ) { + configParam.withCredentials = providerDefaults.withCredentials; + } + + const securityContext: RequestPolicyContext = { + operation: "request", + method: configParam.method, + url: configParam.url, + requestInit: { + method: configParam.method, + headers: headers as HeadersInit, + credentials: configParam.withCredentials ? "include" : "same-origin", + }, + headers: securityHeaders, + hasBody: !isUndefined(reqData), }; - $http.pendingRequests = []; - - $http.get = createShortMethod("GET"); - $http.delete = createShortMethod("DELETE"); - $http.head = createShortMethod("HEAD"); - $http.post = createShortMethodWithData("POST"); - $http.put = createShortMethodWithData("PUT"); - $http.patch = createShortMethodWithData("PATCH"); - - /** - * Exposes the runtime equivalent of `$httpProvider.defaults`. - * It allows configuration of default headers, `withCredentials`, and request/response transforms. - * - * See "Setting HTTP Headers" and "Transforming Requests and Responses" sections above. - */ - $http.defaults = defaults; - - return $http; - - /** Creates one shorthand method for requests that do not send a request body. */ - function createShortMethod(method: HttpMethod): HttpService["get"] { - return async function ( - url: string, - config?: RequestShortcutConfig, - ): HttpPromise { - return $http( - extend({}, config ?? {}, { - method, - url, - }) as RequestConfig, - ); + const securityDecision = await $security.check(securityContext); + + if (securityDecision.type === "deny") { + const rejectedResponse = { + data: securityDecision, + status: securityDecision.status ?? 403, + headers: headersGetter({}), + config: configParam, + statusText: + securityDecision.reason ?? "Navigation denied by security policy", + xhrStatus: "error" as const, }; + + return Promise.reject(new HttpError(rejectedResponse)); } - /** Creates one shorthand method for requests that send a request body. */ - function createShortMethodWithData( - method: HttpMethod, - ): HttpService["post"] { - return async function ( - url: string, - data: unknown, - config?: RequestShortcutConfig, - ): HttpPromise { - return $http( - extend({}, config ?? {}, { - method, - url, - data, - }) as RequestConfig, - ); + if (securityDecision.type === "redirect") { + const rejectedResponse = { + data: securityDecision, + status: securityDecision.status ?? 302, + headers: headersGetter({}), + config: configParam, + statusText: + securityDecision.reason ?? "Request redirected by security policy", + xhrStatus: "error" as const, }; + + return Promise.reject(new HttpError(rejectedResponse)); } - /** Sends the request through the low-level HTTP backend and cache layer. */ - async function sendReq(config: RequestConfig, reqData: unknown) { - const { promise, resolve, reject } = withResolvers(); + const credentials = await $security.attachRequestAuth(securityContext); + applySecurityCredentials(configParam, credentials); - let cache: HttpCacheLike | undefined; + // send request + return sendReq(configParam, reqData).then( + transformResponse, + transformResponse, + ); + } - let cachedResp: unknown; + function applySecurityCredentials( + configParam: HttpRequestConfig, + credentials?: SecurityRequestCredentials, + ): void { + if (!credentials) { + return; + } - const reqHeaders = config.headers ?? {}; + const headers = configParam.headers as Record; - config.headers = reqHeaders; + if (credentials.headers) { + entries(credentials.headers).forEach(([header, value]) => { + headers[header] = value; + }); + } - let { url } = config; + if (credentials.withCredentials !== undefined) { + configParam.withCredentials = credentials.withCredentials; + } - if (!isString(url)) { - // If it is not a string then the URL must be a $sce trusted object - url = String($sce.valueOf(url)); - } + if (!credentials.credential) { + return; + } - const paramSerializer = config.paramSerializer as HttpParamSerializer; + const value = credentials.credential.value; - url = buildUrl(url, paramSerializer(config.params)); + if (credentials.credential.kind === "jwt") { + headers.Authorization = `Bearer ${value}`; + } else if (credentials.credential.kind === "basic") { + headers.Authorization = `Basic ${value}`; + } + } - $http.pendingRequests.push(config); - void promise.then(removePendingReq, removePendingReq).catch(() => { - return undefined; - }); + /** Applies response transforms and rejects responses outside the success range. */ + async function transformResponse(response: unknown) { + const httpResponse = response as HttpResponse; + + // make a copy since the response must be cacheable + const resp = extend({}, httpResponse) as HttpResponse; + + resp.data = transformData( + httpResponse.data, + httpResponse.headers, + httpResponse.status, + (config.transformResponse as + | (( + data: unknown, + headers: HttpHeadersGetter, + status?: number, + ) => unknown) + | (( + data: unknown, + headers: HttpHeadersGetter, + status?: number, + ) => unknown)[] + | undefined) ?? [], + ); - if ( - (config.cache || defaults.cache) && - config.cache !== false && - config.method === "GET" - ) { - const providerDefaults = defaults; - - cache = isObject(config.cache) - ? (config.cache as typeof cache) - : isObject(providerDefaults.cache) - ? (providerDefaults.cache as typeof cache) - : defaultCache; - } + return isSuccess(httpResponse.status) + ? resp + : Promise.reject(new HttpError(resp)); + } + } as HttpService & { + pendingRequests: HttpRequestConfig[]; + defaults: HttpDefaults; + [key: string]: unknown; + }; - if (cache) { - cachedResp = cache.get(url); - - if (isDefined(cachedResp)) { - if (isPromiseLike>(cachedResp)) { - // cached request has already been sent, but there is no response yet - void Promise.resolve(cachedResp) - .then(resolvePromiseWithResult, resolvePromiseWithResult) - .catch(() => undefined); - } else { - // serving from cache - if (isArray(cachedResp)) { - resolvePromise( - cachedResp[1], - cachedResp[0] as number, - shallowCopy(cachedResp[2]) as Record, - cachedResp[3] as string, - cachedResp[4] as HttpResponseStatus, - ); - } else { - resolvePromise(cachedResp, Http._OK, {}, "OK", "complete"); - } - } - } else { - // put the promise for the non-transformed response into cache as a placeholder - cache.set(url, promise); - } - } + $http.pendingRequests = []; - // if we won't have the response in cache, set the xsrf headers and - // send the request to the backend - if (isUndefined(cachedResp)) { - const xsrfCookieName = - config.xsrfCookieName ?? defaults.xsrfCookieName; + $http.get = createShortMethod("GET"); + $http.delete = createShortMethod("DELETE"); + $http.head = createShortMethod("HEAD"); + $http.post = createShortMethodWithData("POST"); + $http.put = createShortMethodWithData("PUT"); + $http.patch = createShortMethodWithData("PATCH"); - const xsrfValue = - xsrfCookieName && urlIsAllowedOrigin(config.url) - ? $cookie.getAll()[xsrfCookieName] - : undefined; + /** + * Exposes the live configured HTTP defaults. + * It allows configuration of default headers, `withCredentials`, and request/response transforms. + * + * See "Setting HTTP Headers" and "Transforming Requests and Responses" sections above. + */ + $http.defaults = defaults; + + return $http; + + /** Creates one shorthand method for requests that do not send a request body. */ + function createShortMethod(method: HttpMethod): HttpService["get"] { + return async function ( + url: string, + config?: HttpRequestOptions, + ): Promise> { + return $http( + extend({}, config ?? {}, { + method, + url, + }) as HttpRequestConfig, + ); + }; + } - if (xsrfValue) { - const xsrfHeaderName = - config.xsrfHeaderName ?? defaults.xsrfHeaderName; + /** Creates one shorthand method for requests that send a request body. */ + function createShortMethodWithData(method: HttpMethod): HttpService["post"] { + return async function ( + url: string, + data: unknown, + config?: HttpRequestOptions, + ): Promise> { + return $http( + extend({}, config ?? {}, { + method, + url, + data, + }) as HttpRequestConfig, + ); + }; + } - if (xsrfHeaderName) { - reqHeaders[xsrfHeaderName] = xsrfValue; - } - } + /** Sends the request through the low-level HTTP backend and cache layer. */ + async function sendReq(config: HttpRequestConfig, reqData: unknown) { + const { promise, resolve, reject } = withResolvers(); - http( - config.method, - url, - reqData, - done, - reqHeaders as Record, - config.timeout, - config.withCredentials, - config.responseType, - createEventHandlers(config.eventHandlers), - createEventHandlers(config.uploadEventHandlers), - $stream, - ); - } + let cache: HttpCacheLike | undefined; + + let cachedResp: unknown; + + const reqHeaders = assertDefined(config.headers); + + config.headers = reqHeaders; + + let { url } = config; + + if (!isString(url)) { + // If it is not a string then the URL must be a $sce trusted object + url = String($sce.valueOf(url)); + } - return promise; - - /** Wraps raw transport event handlers with function/object listener support. */ - function createEventHandlers( - eventHandlers: RequestConfig["eventHandlers"], - ): Record { - if (eventHandlers) { - const handlers: Record = {}; - - entries(eventHandlers).forEach(([key, eventHandler]) => { - handlers[key] = function (event: Event) { - if (isFunction(eventHandler)) { - eventHandler(event); - } else if (typeof eventHandler === "object") { - eventHandler.handleEvent(event); - } - }; - }); - - return handlers; + const paramSerializer = config.paramSerializer as HttpParamSerializer; + + url = buildUrl(url, paramSerializer(config.params)); + + $http.pendingRequests.push(config); + void promise.then(removePendingReq, removePendingReq); + + if ( + (config.cache || defaults.cache) && + config.cache !== false && + config.method === "GET" + ) { + const providerDefaults = defaults; + + cache = isObject(config.cache) + ? (config.cache as typeof cache) + : isObject(providerDefaults.cache) + ? (providerDefaults.cache as typeof cache) + : defaultCache; + } + + if (cache) { + cachedResp = cache.get(url); + + if (isDefined(cachedResp)) { + if (isPromiseLike>(cachedResp)) { + // cached request has already been sent, but there is no response yet + void Promise.resolve(cachedResp) + .then(resolvePromiseWithResult, resolvePromiseWithResult) + .catch(reject); + } else { + // serving from cache + if (isArray(cachedResp)) { + resolvePromise( + cachedResp[1], + cachedResp[0] as number, + shallowCopy(cachedResp[2]) as Record, + cachedResp[3] as string, + cachedResp[4] as HttpResponseStatus, + ); } else { - return {}; + resolvePromise(cachedResp, Http._OK, {}, "OK", "complete"); } } + } else { + // put the promise for the non-transformed response into cache as a placeholder + cache.set(url, promise); + } + } - /** Handles low-level transport completion, updates cache state, and settles the raw `$http` promise. */ - function done( - status: number, - response: unknown, - headersString: string | null, - statusText: string, - xhrStatus: HttpResponseStatus, - ): void { - if (cache) { - if (isSuccess(status)) { - cache.set(url, [ - status, - response, - parseHeaders(headersString ?? ""), - statusText, - xhrStatus, - ]); - } else { - // remove promise from the cache - cache.delete(url); - } - } + // if we won't have the response in cache, set the xsrf headers and + // send the request to the backend + if (isUndefined(cachedResp)) { + const xsrfCookieName = config.xsrfCookieName ?? defaults.xsrfCookieName; - resolvePromise( - response, - status, - headersString, - statusText, - xhrStatus, - ); + const xsrfValue = + xsrfCookieName && urlIsAllowedOrigin(config.url) + ? $cookie.getAll()[xsrfCookieName] + : undefined; + + if (xsrfValue) { + const xsrfHeaderName = config.xsrfHeaderName ?? defaults.xsrfHeaderName; + + if (xsrfHeaderName) { + reqHeaders[xsrfHeaderName] = xsrfValue; } + } - /** Resolves or rejects the raw `$http` promise from a low-level transport callback payload. */ - function resolvePromise( - response: unknown, - status: number, - headers: string | Record | null, - statusText: string, - xhrStatus: HttpResponseStatus, - ): void { - // status: HTTP response status code, 0, -1 (aborted by timeout / promise) - status = status >= -1 ? status : 0; - - (isSuccess(status) ? resolve : reject)({ - data: response, + http( + config.method, + url, + reqData, + done, + reqHeaders as Record, + config.timeout, + config.withCredentials, + config.responseType, + createEventHandlers(config.eventHandlers), + createEventHandlers(config.uploadEventHandlers), + $stream, + ); + } + + return promise; + + /** Wraps raw transport event handlers with function/object listener support. */ + function createEventHandlers( + eventHandlers: HttpRequestConfig["eventHandlers"], + ): Record { + if (eventHandlers) { + const handlers: Record = {}; + + entries(eventHandlers).forEach(([key, eventHandler]) => { + handlers[key] = function (event: Event) { + if (isFunction(eventHandler)) { + eventHandler(event); + } else if (typeof eventHandler === "object") { + eventHandler.handleEvent(event); + } + }; + }); + + return handlers; + } else { + return {}; + } + } + + /** Handles low-level transport completion, updates cache state, and settles the raw `$http` promise. */ + function done( + status: number, + response: unknown, + headersString: string | null, + statusText: string, + xhrStatus: HttpResponseStatus, + ): void { + if (cache) { + if (isSuccess(status)) { + cache.set(url, [ status, - headers: headersGetter(headers ?? ""), - config, + response, + parseHeaders(headersString), statusText, xhrStatus, - }); + ]); + } else { + // remove promise from the cache + cache.delete(url); } + } - /** Settles the raw `$http` promise from a cached or intercepted response object. */ - function resolvePromiseWithResult(result: HttpResponse): void { - resolvePromise( - result.data, - result.status, - shallowCopy(result.headers()), - result.statusText, - result.xhrStatus, - ); - } + resolvePromise(response, status, headersString, statusText, xhrStatus); + } + + /** Resolves or rejects the raw `$http` promise from a low-level transport callback payload. */ + function resolvePromise( + response: unknown, + status: number, + headers: string | Record | null, + statusText: string, + xhrStatus: HttpResponseStatus, + ): void { + // status: HTTP response status code, 0, -1 (aborted by timeout / promise) + status = status >= -1 ? status : 0; + + (isSuccess(status) ? resolve : reject)({ + data: response, + status, + headers: headersGetter(headers ?? ""), + config, + statusText, + xhrStatus, + }); + } - /** Removes the finished request config from `$http.pendingRequests`. */ - function removePendingReq() { - const idx = $http.pendingRequests.indexOf(config); + /** Settles the raw `$http` promise from a cached or intercepted response object. */ + function resolvePromiseWithResult(result: HttpResponse): void { + resolvePromise( + result.data, + result.status, + shallowCopy(result.headers()), + result.statusText, + result.xhrStatus, + ); + } - if (idx !== -1) $http.pendingRequests.splice(idx, 1); - } - } + /** Removes the finished request config from `$http.pendingRequests`. */ + function removePendingReq() { + const idx = $http.pendingRequests.indexOf(config); - /** Appends a serialized query string to a URL when request parameters are present. */ - function buildUrl(url: string, serializedParams: string): string { - if (serializedParams.length > 0) { - url += (!url.includes("?") ? "?" : "&") + serializedParams; - } + if (idx !== -1) $http.pendingRequests.splice(idx, 1); + } + } - return url; - } - }, - ]; + /** Appends a serialized query string to a URL when request parameters are present. */ + function buildUrl(url: string, serializedParams: string): string { + if (serializedParams.length > 0) { + url += (!url.includes("?") ? "?" : "&") + serializedParams; + } + + return url; + } } /** @@ -1323,8 +1441,8 @@ export function http( timeout?: number | Promise, withCredentials?: boolean, responseType?: string, - eventHandlers?: RequestConfig["eventHandlers"], - uploadEventHandlers?: RequestConfig["uploadEventHandlers"], + eventHandlers?: HttpRequestConfig["eventHandlers"], + uploadEventHandlers?: HttpRequestConfig["uploadEventHandlers"], streamService?: ng.StreamService, ): void { url = url ?? trimEmptyHash(window.location.href); @@ -1511,7 +1629,7 @@ function responseHeadersString(headers: Headers): string { /** Notifies configured transport event handlers for compatibility. */ function notifyEvent( eventName: string, - eventHandlers: RequestConfig["eventHandlers"], + eventHandlers: HttpRequestConfig["eventHandlers"], ): void { if (!eventHandlers) return; diff --git a/src/services/http/template-request.spec.ts b/src/services/http/template-request.spec.ts index ebb37b034..427f9d16c 100644 --- a/src/services/http/template-request.spec.ts +++ b/src/services/http/template-request.spec.ts @@ -3,12 +3,19 @@ import { createInjector } from "../../core/di/injector.ts"; import { Angular } from "../../angular.ts"; import { wait } from "../../shared/test-utils.ts"; +import { defaultHttpResponseTransform } from "./http.ts"; +import { + applyTemplateRequestConfig, + createTemplateRequestHttpOptions, + createTemplateRequestService, +} from "../template-request/template-request.ts"; describe("$templateRequest", () => { let module, $rootScope, $templateRequest, $templateCache, + $http, $sce, angular, errors; @@ -28,49 +35,74 @@ describe("$templateRequest", () => { $rootScope = injector.get("$rootScope"); $templateRequest = injector.get("$templateRequest"); $templateCache = injector.get("$templateCache"); + $http = injector.get("$http"); $sce = injector.get("$sce"); }); - describe("provider", () => { - describe("httpOptions", () => { - it("should default to { headers: { Accept: 'text/html' } } and fallback to default $http options", () => { - angular.module("test", [ - "ng", - ($templateRequestProvider) => { - expect($templateRequestProvider.httpOptions).toEqual({ - headers: { Accept: "text/html" }, - }); - }, - ]); + it("keeps default options when configuration has no HTTP options", () => { + const options = createTemplateRequestHttpOptions(); - createInjector(["test"]).invoke( - async ($templateRequest, $http, $templateCache) => { - spyOn($http, "get").and.callThrough(); + expect(applyTemplateRequestConfig(options, {})).toBe(options); + }); - $templateRequest("/public/test.html"); - expect($http.get).toHaveBeenCalledOnceWith("/public/test.html", { - cache: $templateCache, - transformResponse: [], - headers: { Accept: "text/html" }, - }); - await wait(); - }, - ); + it("removes a lone default response transform", async () => { + const cache = new Map(); + const http = { + defaults: { transformResponse: defaultHttpResponseTransform }, + get: jasmine.createSpy("get").and.resolveTo({ data: "template" }), + }; + const request = createTemplateRequestService(cache, http, {}); + + await expectAsync(request("/template.html")).toBeResolvedTo("template"); + + expect(http.get).toHaveBeenCalledOnceWith("/template.html", { + cache, + transformResponse: null, + }); + expect(cache.get("/template.html")).toBe("template"); + }); + + it("uses null when HTTP has no default response transform", async () => { + const cache = new Map(); + const http = { + defaults: {}, + get: jasmine.createSpy("get").and.resolveTo({ data: "template" }), + }; + const request = createTemplateRequestService(cache, http, {}); + + await request("/plain-template.html"); + + expect(http.get).toHaveBeenCalledOnceWith("/plain-template.html", { + cache, + transformResponse: null, + }); + }); + + describe("configuration", () => { + describe("httpOptions", () => { + it("should default to { headers: { Accept: 'text/html' } } and fallback to default $http options", async () => { + spyOn($http, "get").and.callThrough(); + + $templateRequest("/public/test.html"); + expect($http.get).toHaveBeenCalledOnceWith("/public/test.html", { + cache: $templateCache, + transformResponse: [], + headers: { Accept: "text/html" }, + }); + await wait(); }); it("should be configurable", () => { function someTransform() {} - angular.module("test", [ - "ng", - ($templateRequestProvider) => { - // Configure the template request service to provide specific headers and transforms - $templateRequestProvider.httpOptions = { + angular.module("test", ["ng"]).config({ + $templateRequest: { + httpOptions: { headers: { Accept: "moo" }, transformResponse: [someTransform], - }; + }, }, - ]); + }); createInjector(["test"]).invoke( ($templateRequest, $http, $templateCache) => { @@ -88,27 +120,25 @@ describe("$templateRequest", () => { }); it("should be allow you to override the cache", () => { - const httpOptions = {}; + const customCache = new Map(); - angular.module("test", [ - "ng", - ($templateRequestProvider) => { - $templateRequestProvider.httpOptions = httpOptions; + angular.module("test", ["ng"]).config({ + $templateRequest: { + httpOptions: { + cache: customCache, + }, }, - ]); + }); createInjector(["test"]).invoke(($templateRequest, $http) => { spyOn($http, "get").and.callThrough(); - const customCache = new Map(); - - httpOptions.cache = customCache; - $templateRequest("/public/test.html"); expect($http.get).toHaveBeenCalledOnceWith("/public/test.html", { cache: customCache, transformResponse: [], + headers: { Accept: "text/html" }, }); }); }); @@ -183,14 +213,7 @@ describe("$templateRequest", () => { }); it("should use custom response transformers (array)", async () => { - module.config(($httpProvider) => { - $httpProvider.defaults.transformResponse.push((data) => `${data}!!`); - }); - const injector = createInjector(["test"]); - - $rootScope = injector.get("$rootScope"); - $templateRequest = injector.get("$templateRequest"); - $templateCache = injector.get("$templateCache"); + $http.defaults.transformResponse.push((data) => `${data}!!`); const spy = jasmine.createSpy("success"); @@ -199,16 +222,9 @@ describe("$templateRequest", () => { }); it("should use custom response transformers (function)", async () => { - module.config(($httpProvider) => { - $httpProvider.defaults.transformResponse = function (data) { - return `${data}!!`; - }; - }); - const injector = createInjector(["test"]); - - $rootScope = injector.get("$rootScope"); - $templateRequest = injector.get("$templateRequest"); - $templateCache = injector.get("$templateCache"); + $http.defaults.transformResponse = function (data) { + return `${data}!!`; + }; const spy = jasmine.createSpy("success"); await $templateRequest("/mock/jsoninterpolation").then(spy); diff --git a/src/services/location/README.md b/src/services/location/README.md new file mode 100644 index 000000000..97bdfd98a --- /dev/null +++ b/src/services/location/README.md @@ -0,0 +1,110 @@ +# Location Internals + +This directory owns browser URL normalization, history synchronization, and +application link rewriting. `location.ts` separates the public `Location` +service from runtime-owned browser state and typed `LocationConfig` policy. + +## Responsibilities + +- Parse and compose path, query, hash, and History API state. +- Synchronize `$location` changes with `window.location` and `window.history`. +- Rewrite eligible application links according to location policy. +- Remove root click and browser navigation listeners during teardown. + +## Public Surface + +- `Location`: the injectable `$location` service contract. +- `LocationConfig`: typed configuration accepted by + `module.config({ $location: ... })`. +- `Html5Mode`: HTML5 mode, base-tag, and link-rewriting policy. +- `UrlChangeListener`: callback shape for browser URL changes. + +Applications configure URL behavior without injecting a provider: + +```ts +app.config({ + $location: { + html5Mode: { + enabled: true, + requireBase: false, + rewriteLinks: true, + }, + hashPrefix: "!", + }, +}); +``` + +## Core Model + +`LocationRuntimeState` is internal composition state. It owns normalized +configuration, cached browser history state, native listeners, and lazy service +construction. The same stable configuration object is passed to router +composition, so `$location` parsing and router href generation cannot diverge. + +The main flow is: + +1. Browser composition creates location runtime state and registers typed + configuration. +2. Module configuration mutates the normalized policy before service creation. +3. Injecting `$location` creates the service and connects root/browser events. +4. Runtime or root destruction removes every owned listener. + +## Lifecycle Contract + +- Creating runtime state reads the current browser URL and history state. +- Injecting `$location` installs root click, `popstate`, and `hashchange` + integration lazily. +- Root destruction disconnects service listeners for that application root. +- Runtime destruction disposes remaining listeners and rejects later setup or + configuration. + +## Reactivity Contract + +- Browser navigation broadcasts `$locationChangeStart` and + `$locationChangeSuccess` through the root scope. +- Application URL writes are scheduled and then reflected into browser history. +- Router synchronization consumes the same `$location` service state. + +## Policy Contract + +- HTML5 mode defaults to enabled without requiring a `` element. +- Link rewriting defaults to enabled and may be disabled or restricted to a + named attribute. +- Hashbang URLs default to the `!` prefix. +- Policy is applied during module configuration and is fixed before lazy + `$location` construction. + +## Composition Contract + +- Browser runtime composition owns native history and listener state. +- Router composition receives only `LocationConfig`; it does not know how the + service or browser listeners are constructed. +- `$location` remains independently usable in applications without a router. + +## Failure Contract + +- Requiring a missing `` element throws during service construction. +- Failed History API writes restore the previous service state and report the + failure through `$exceptionHandler`. +- Configuration or service creation after runtime disposal throws a stable + lifecycle error. + +## Scheduling And Ordering + +- Browser `popstate` and `hashchange` processing enters through a microtask. +- Application-driven browser updates are deferred until the current state + mutation completes. +- `$locationChangeStart` may cancel a change before it is committed. + +## Native Interop + +The native boundary is `window.location`, `window.history`, root `click`, +`popstate`, and `hashchange`. Applications needing direct browser operations may +still use those APIs, but must coordinate resulting navigation with `$location`. + +## Test Harness + +- `location.spec.ts` covers URL parsing, configuration, browser history, + listener lifecycle, and service behavior. +- Router specs cover hash/HTML5 href generation from shared location policy. +- `location.test.ts` runs the browser suite through Playwright. diff --git a/src/services/location/location.spec.ts b/src/services/location/location.spec.ts index c15215c2f..60f39dcf1 100644 --- a/src/services/location/location.spec.ts +++ b/src/services/location/location.spec.ts @@ -6,7 +6,8 @@ import { isLinkRewritingEnabled, normalizePath, Location, - LocationProvider, + applyLocationConfiguration, + createLocationRuntimeState, parseAppUrl, stripBaseUrl, stripHash, @@ -15,7 +16,7 @@ import { urlsEqual, } from "./location.ts"; import { Angular } from "../../angular.ts"; -import { createInjector } from "../../core/di/injector.ts"; +import { wait } from "../../shared/test-utils.ts"; describe("$location", () => { let module; @@ -25,56 +26,386 @@ describe("$location", () => { module = window.angular.module("test1", ["ng"]); }); - // function initService(options) { - // module.config(($provide, $locationProvider) => { - // $locationProvider.setHtml5Mode(options.html5Mode); - // $locationProvider.hashPrefix(options.hashPrefix); - // $provide.value("$sniffer", { history: options.supportHistory }); - // }); - // } - describe("defaults", () => { it('should have hashPrefix of "!"', () => { - const provider = new LocationProvider(); + const state = createLocationRuntimeState(window); - expect(provider.hashPrefixConf).toBe("!"); + expect(state.config.hashPrefix).toBe("!"); }); it("should default to html5 mode with no base and rewrite links", () => { - const provider = new LocationProvider(); + const state = createLocationRuntimeState(window); - expect(provider.html5ModeConf.enabled).toBeTrue(); - expect(provider.html5ModeConf.requireBase).toBeFalse(); - expect(provider.html5ModeConf.rewriteLinks).toBeTrue(); + expect(state.config.html5Mode.enabled).toBeTrue(); + expect(state.config.html5Mode.requireBase).toBeFalse(); + expect(state.config.html5Mode.rewriteLinks).toBeTrue(); }); it("should enable default rewriteLinks", () => { - const provider = new LocationProvider(); + const state = createLocationRuntimeState(window); - expect(isLinkRewritingEnabled(provider.html5ModeConf.rewriteLinks)).toBe( + expect(isLinkRewritingEnabled(state.config.html5Mode.rewriteLinks)).toBe( true, ); }); it("should allow disabling rewriteLinks", () => { - const provider = new LocationProvider(); + const state = createLocationRuntimeState(window); - provider.html5ModeConf.rewriteLinks = false; + applyLocationConfiguration(state, { + html5Mode: { rewriteLinks: false }, + }); - expect(isLinkRewritingEnabled(provider.html5ModeConf.rewriteLinks)).toBe( + expect(isLinkRewritingEnabled(state.config.html5Mode.rewriteLinks)).toBe( false, ); }); it("should allow explicitly forcing rewriteLinks on", () => { - const provider = new LocationProvider(); + const state = createLocationRuntimeState(window); - provider.html5ModeConf.rewriteLinks = true; + applyLocationConfiguration(state, { + html5Mode: { rewriteLinks: true }, + }); - expect(isLinkRewritingEnabled(provider.html5ModeConf.rewriteLinks)).toBe( + expect(isLinkRewritingEnabled(state.config.html5Mode.rewriteLinks)).toBe( true, ); }); + + it("rejects configuration after runtime disposal", () => { + const state = createLocationRuntimeState(window); + + state.destroy(); + + expect(() => + applyLocationConfiguration(state, { hashPrefix: "~" }), + ).toThrowError("Location runtime has already been disposed."); + }); + + it("owns and disposes browser URL listeners", () => { + const state = createLocationRuntimeState(window); + const addEventListener = spyOn( + window, + "addEventListener", + ).and.callThrough(); + const removeEventListener = spyOn( + window, + "removeEventListener", + ).and.callThrough(); + + state._onUrlChange(() => undefined); + state.destroy(); + state.destroy(); + + expect(addEventListener).toHaveBeenCalledWith( + "popstate", + jasmine.any(Function), + ); + expect(addEventListener).toHaveBeenCalledWith( + "hashchange", + jasmine.any(Function), + ); + expect(removeEventListener).toHaveBeenCalledWith( + "popstate", + jasmine.any(Function), + ); + expect(removeEventListener).toHaveBeenCalledWith( + "hashchange", + jasmine.any(Function), + ); + expect(() => state._onUrlChange(() => undefined)).toThrowError( + "Location runtime has already been disposed.", + ); + }); + }); + + describe("browser runtime", () => { + function createHarness(config = {}) { + const windowListeners = {}; + const scopeListeners = {}; + const broadcasts = []; + const exceptions = []; + const fakeLocation = { href: "http://localhost/" }; + const fakeHistory = { + state: null, + pushState(state, _title, url) { + this.state = state; + fakeLocation.href = String(url); + }, + }; + const fakeWindow = { + location: fakeLocation, + history: fakeHistory, + addEventListener(name, listener) { + windowListeners[name] = listener; + }, + removeEventListener(name, listener) { + if (windowListeners[name] === listener) delete windowListeners[name]; + }, + }; + let onBroadcast; + const rootScope = { + $on(name, listener) { + scopeListeners[name] = listener; + + return () => delete scopeListeners[name]; + }, + $broadcast(name, ...args) { + const event = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + + broadcasts.push([name, ...args]); + onBroadcast?.(name, event, ...args); + + return event; + }, + }; + const rootElement = document.createElement("div"); + const runtime = createLocationRuntimeState(fakeWindow); + + applyLocationConfiguration(runtime, config); + + const location = runtime.createService(rootScope, rootElement, (error) => + exceptions.push(error), + ); + + return { + broadcasts, + exceptions, + fakeHistory, + fakeLocation, + location, + rootElement, + runtime, + scopeListeners, + setBroadcastHandler(handler) { + onBroadcast = handler; + }, + windowListeners, + }; + } + + it("requires a base element when configured", () => { + const runtime = createLocationRuntimeState({ + location: { href: "http://localhost/" }, + history: { state: null }, + }); + + applyLocationConfiguration(runtime, { + html5Mode: { requireBase: true }, + }); + + expect(() => + runtime.createService( + { $on() {}, $broadcast() {} }, + document.createElement("div"), + () => undefined, + ), + ).toThrowError(/requires a tag/); + }); + + it("rolls location state back when history updates fail", async () => { + const harness = createHarness(); + + await wait(); + harness.fakeHistory.pushState = () => { + throw new Error("history failed"); + }; + + harness.location.setPath("/next"); + await wait(); + await wait(); + + expect(harness.location.getPath()).toBe("/"); + expect(harness.exceptions[0]).toEqual(jasmine.any(Error)); + }); + + it("rewrites only eligible application links", () => { + const harness = createHarness({ + html5Mode: { rewriteLinks: "data-internal" }, + }); + const ignored = document.createElement("a"); + const accepted = document.createElement("a"); + + ignored.href = "http://localhost/ignored"; + accepted.href = "http://localhost/accepted"; + accepted.setAttribute("data-internal", ""); + harness.rootElement.append(ignored, accepted); + + const eventFor = (target, overrides = {}) => ({ + button: 0, + ctrlKey: false, + defaultPrevented: false, + metaKey: false, + preventDefault: jasmine.createSpy("preventDefault"), + shiftKey: false, + target, + ...overrides, + }); + const ignoredEvent = eventFor(ignored); + const modifiedEvent = eventFor(accepted, { ctrlKey: true }); + const acceptedEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + + harness.runtime._rootClickHandler(ignoredEvent); + harness.runtime._rootClickHandler(modifiedEvent); + accepted.dispatchEvent(acceptedEvent); + + expect(ignoredEvent.preventDefault).not.toHaveBeenCalled(); + expect(modifiedEvent.preventDefault).not.toHaveBeenCalled(); + expect(acceptedEvent.defaultPrevented).toBeTrue(); + expect(harness.location.getPath()).toBe("/accepted"); + }); + + it("ignores non-link and unsafe link clicks", () => { + const harness = createHarness(); + const span = document.createElement("span"); + const unsafe = document.createElement("a"); + + unsafe.href = "javascript:void(0)"; + harness.rootElement.append(span, unsafe); + + const spanEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + const unsafeEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + + span.dispatchEvent(spanEvent); + unsafe.dispatchEvent(unsafeEvent); + + expect(spanEvent.defaultPrevented).toBeFalse(); + expect(unsafeEvent.defaultPrevented).toBeFalse(); + }); + + it("normalizes SVG animated link hrefs", () => { + const harness = createHarness(); + const event = { + button: 0, + ctrlKey: false, + defaultPrevented: false, + metaKey: false, + preventDefault() { + this.defaultPrevented = true; + }, + shiftKey: false, + target: { + href: { animVal: "http://localhost/vector" }, + getAttribute(name) { + return name === "href" ? "/vector" : null; + }, + nodeName: "a", + }, + }; + + harness.runtime._rootClickHandler(event); + + expect(event.defaultPrevented).toBeTrue(); + expect(harness.location.getPath()).toBe("/vector"); + }); + + it("cancels and redirects incoming browser navigation", async () => { + const harness = createHarness(); + + await wait(); + harness.setBroadcastHandler((name, event) => { + if (name === "$locationChangeStart") event.preventDefault(); + }); + harness.fakeLocation.href = "http://localhost/cancelled"; + harness.windowListeners.popstate(); + await wait(); + + expect(harness.location.getPath()).toBe("/"); + expect(harness.fakeLocation.href).toBe("http://localhost/"); + + harness.setBroadcastHandler((name) => { + if (name === "$locationChangeStart") { + harness.location.setPath("/redirected"); + } + }); + harness.fakeLocation.href = "http://localhost/incoming"; + harness.windowListeners.popstate(); + await wait(); + + expect(harness.location.getPath()).toBe("/redirected"); + }); + + it("cancels and redirects application-driven navigation", async () => { + const harness = createHarness(); + + await wait(); + harness.setBroadcastHandler((name, event) => { + if (name === "$locationChangeStart") event.preventDefault(); + }); + harness.location.setPath("/cancelled"); + await wait(); + await wait(); + + expect(harness.location.getPath()).toBe("/"); + + harness.setBroadcastHandler((name) => { + if (name === "$locationChangeStart") { + harness.location.setPath("/redirected"); + } + }); + harness.location.setPath("/incoming"); + await wait(); + await wait(); + + expect(harness.location.getPath()).toBe("/redirected"); + }); + + it("stops queued navigation work after root destruction", async () => { + const harness = createHarness(); + + harness.fakeLocation.href = "http://localhost/queued"; + harness.windowListeners.popstate(); + harness.scopeListeners.$destroy(); + await wait(); + + expect(harness.location.getPath()).toBe("/"); + expect(harness.windowListeners.popstate).toBeUndefined(); + }); + + it("sends browser navigation outside the application to the platform", async () => { + const harness = createHarness(); + + await wait(); + harness.fakeLocation.href = "https://example.com/outside"; + harness.windowListeners.popstate(); + + expect(harness.fakeLocation.href).toBe("https://example.com/outside"); + expect(harness.location.getPath()).toBe("/"); + }); + + it("stops success notification when start listeners destroy the root", async () => { + const harness = createHarness(); + + await wait(); + harness.broadcasts.length = 0; + harness.setBroadcastHandler((name) => { + if (name === "$locationChangeStart") { + harness.scopeListeners.$destroy(); + } + }); + harness.location.setPath("/destroyed"); + await wait(); + await wait(); + + expect( + harness.broadcasts.some(([name]) => name === "$locationChangeSuccess"), + ).toBeFalse(); + }); }); describe("File Protocol", () => { @@ -139,6 +470,19 @@ describe("$location", () => { return locationUrl; } + it("keeps mutable URL state isolated between instances", () => { + const first = createLocationHtml5Url(); + const second = new Location("http://server/", "http://server/", true); + + second.setUrl("/second?owner=second#two"); + + expect(first.getUrl()).toBe("/path/b?search=a&b=c&d#hash"); + expect(first.getPath()).toBe("/path/b"); + expect(first.getSearch()).toEqual({ search: "a", b: "c", d: true }); + expect(first.getHash()).toBe("hash"); + expect(second.getUrl()).toBe("/second?owner=second#two"); + }); + it("should provide common getters", () => { const locationUrl = createLocationHtml5Url(); @@ -151,6 +495,21 @@ describe("$location", () => { expect(locationUrl.getUrl()).toBe("/path/b?search=a&b=c&d#hash"); }); + it("clears a hash with null", () => { + const locationUrl = createLocationHtml5Url(); + + locationUrl.setHash(null); + + expect(locationUrl.getHash()).toBe(""); + }); + + it("rejects undefined path and hash values passed to explicit setters", () => { + const locationUrl = createLocationHtml5Url(); + + expect(() => locationUrl.setPath(undefined)).toThrowError(/path/); + expect(() => locationUrl.setHash(undefined)).toThrowError(/hash/); + }); + it("path() should change path", () => { const locationUrl = createLocationHtml5Url(); @@ -1170,15 +1529,6 @@ describe("$location", () => { }); describe("parseAppUrl", () => { - let locationObj; - - beforeEach(() => { - locationObj = new Location( - "http://www.domain.com:9877/", - "http://www.domain.com:9877/", - ); - }); - it("should throw error on url starting with // or \\\\", () => { expect(() => parseAppUrl("//invalid/path", false)).toThrowError( /Invalid url/, @@ -1189,37 +1539,34 @@ describe("$location", () => { }); it("should add leading slash if missing and parse url correctly", () => { - parseAppUrl("some/path?foo=bar#hashValue", false); - expect(locationObj.getPath()).toBe("/some/path"); - expect(locationObj.getSearch()).toEqual( - jasmine.objectContaining({ foo: "bar" }), - ); - expect(locationObj.getHash()).toBe("hashValue"); + const result = parseAppUrl("some/path?foo=bar#hashValue", false); + + expect(result.path).toBe("/some/path"); + expect(result.search).toEqual(jasmine.objectContaining({ foo: "bar" })); + expect(result.hash).toBe("hashValue"); }); it("should keep leading slash if present", () => { - parseAppUrl("/already/slashed?x=1#abc", locationObj, false); - expect(locationObj.getPath()).toBe("/already/slashed"); - expect(locationObj.getSearch()).toEqual( - jasmine.objectContaining({ x: "1" }), - ); - expect(locationObj.getHash()).toBe("abc"); + const result = parseAppUrl("/already/slashed?x=1#abc", false); + + expect(result.path).toBe("/already/slashed"); + expect(result.search).toEqual(jasmine.objectContaining({ x: "1" })); + expect(result.hash).toBe("abc"); }); it("should remove leading slash from path if prefixed and path starts with slash", () => { - parseAppUrl("foo/bar", locationObj, false); - expect(locationObj.getPath().charAt(0)).toBe("/"); - expect(locationObj.getPath()).toBe("/foo/bar"); + const result = parseAppUrl("foo/bar", false); + + expect(result.path.charAt(0)).toBe("/"); + expect(result.path).toBe("/foo/bar"); }); it("should set empty $$search when no query params", () => { - parseAppUrl("/pathOnly", locationObj, false); - expect(locationObj.getSearch()).toEqual({}); + expect(parseAppUrl("/pathOnly", false).search).toEqual({}); }); it("should decode hash correctly", () => { - parseAppUrl("/path#%23encoded", locationObj, false); - expect(locationObj.getHash()).toBe("#encoded"); + expect(parseAppUrl("/path#%23encoded", false).hash).toBe("#encoded"); }); }); @@ -2986,13 +3333,13 @@ describe("$location", () => { // it("should not mess up hash urls when clicking on links in hashbang mode with a prefix", () => { // let base; - // module( - // ($locationProvider) => + // module.config({ $location: { hashPrefix: "!!" } }); + // module._config( + // () => // function ($browser) { // window.location.hash = "!!someHash"; // $browser.url((base = window.location.href)); // base = base.split("#")[0]; - // $locationProvider.hashPrefix("!!"); // }, // ); // inject( @@ -3116,13 +3463,13 @@ describe("$location", () => { // it("should not throw when clicking an SVGAElement link", () => { // let base; - // module( - // ($locationProvider) => + // module.config({ $location: { hashPrefix: "!" } }); + // module._config( + // () => // function ($browser) { // window.location.hash = "!someHash"; // $browser.url((base = window.location.href)); // base = base.split("#")[0]; - // $locationProvider.hashPrefix("!"); // }, // ); // inject( @@ -3536,8 +3883,8 @@ describe("$location", () => { // }); // it("should listen on click events on href and prevent browser default in html5 mode", () => { - // module(($locationProvider, $provide) => { - // $locationProvider.setHtml5Mode(true); + // module.config({ $location: { html5Mode: true } }); + // module._config(($provide) => { // return function ($rootElement, $compile, $rootScope) { // $rootElement.html('link'); // $compile($rootElement)($rootScope); @@ -3595,18 +3942,21 @@ describe("$location", () => { // })); // }); - describe("$locationProvider", () => { - describe("html5ModeConf", () => { - it("should have default values", () => { - module.config(($locationProvider) => { - expect($locationProvider.html5ModeConf).toEqual({ - enabled: true, - requireBase: false, - rewriteLinks: true, - }); - }); - createInjector(["test1"]); + describe("runtime configuration", () => { + it("applies typed policy before creating the service", () => { + module.config({ + $location: { + html5Mode: false, + hashPrefix: "~", + }, }); + + const location = window.angular + .bootstrap(document.createElement("div"), ["test1"]) + .get("$location"); + + expect(location.html5).toBeFalse(); + expect(location.hashPrefix).toBe("#~"); }); }); @@ -3699,9 +4049,7 @@ describe("$location", () => { // it("should complain if no base tag present", () => { // let module = window.angular.module("test1", ["ng"]); - // module.config((_$locationProvider_) => { - // $locationProvider.setHtml5Mode(true); - // }); + // module.config({ $location: { html5Mode: true } }); // createInjector(["test1"]).invoke(($browser, $injector) => { // $browser.$$baseHref = undefined; @@ -3712,11 +4060,10 @@ describe("$location", () => { // }); // it("should not complain if baseOptOut set to true in html5Mode", () => { - // module.config(($locationProvider) => { - // $locationProvider.setHtml5Mode({ - // enabled: true, - // requireBase: false, - // }); + // module.config({ + // $location: { + // html5Mode: { enabled: true, requireBase: false }, + // }, // }); // inject(($browser, $injector) => { @@ -3893,14 +4240,12 @@ describe("$location", () => { // $window, // $log, // $sniffer, - // $$taskTrackerFactory, // ) { // browser = new Browser( // $window, // // $log, // $sniffer, - // $$taskTrackerFactory, // ); // browser.baseHref = () => { // return options.baseHref; diff --git a/src/services/location/location.ts b/src/services/location/location.ts index 5dddd93bd..40fbbf4d5 100644 --- a/src/services/location/location.ts +++ b/src/services/location/location.ts @@ -1,17 +1,10 @@ -import { - _exceptionHandler, - _rootElement, - _rootScope, -} from "../../injection-tokens.ts"; import { trimEmptyHash, urlResolve } from "../../shared/url-utils/url-utils.ts"; import { - callFunction, encodeUriSegment, deleteProperty, entries, equals, isDefined, - isFunction, isNull, isNumber, isObject, @@ -64,6 +57,20 @@ export interface Html5Mode { rewriteLinks: boolean | string; } +/** + * Declarative configuration accepted by `NgModule.config({ $location: ... })`. + */ +export interface LocationConfig { + /** + * Enable/disable HTML5 mode or override individual HTML5 mode fields. + */ + html5Mode?: boolean | Partial; + /** + * Prefix used for hashbang-style URLs. + */ + hashPrefix?: string; +} + /** * Represents default port numbers for various protocols. */ @@ -98,24 +105,8 @@ const PATH_MATCH = /^([^?#]*)(\?([^#]*))?(#(.*))?$/; const $locationError = createErrorFactory("$location"); -let urlUpdatedByLocation = false; - const locationCleanupByRootElement = new WeakMap void>(); -/** - * @ignore - * The pathname, beginning with "/" - */ -let _path = ""; - -let _search: Record = {}; - -/** - * @ignore - * The hash string, minus the hash symbol - */ -let _hash = ""; - /** * @ignore */ @@ -138,6 +129,14 @@ export class Location { _updateBrowser?: () => void; /** @internal */ _state: unknown = undefined; + /** @internal */ + _path = ""; + /** @internal */ + _search: Record = {}; + /** @internal */ + _hash = ""; + /** @internal */ + _urlUpdatedByLocation = false; /** * @param appBase application base URL * @param appBaseNoFile application base URL stripped of any filename @@ -215,13 +214,13 @@ export class Location { * @param path - New path. */ setPath(path: string | number | null) { - validateRequired(path, "path"); + if (isUndefined(path)) validateRequired(path, "path"); let newPath = path !== null ? path.toString() : ""; if (this.html5) { newPath = decodePath(newPath, this.html5); } - _path = newPath.startsWith("/") ? newPath : `/${newPath}`; + this._path = newPath.startsWith("/") ? newPath : `/${newPath}`; this._compose(); return this; @@ -231,7 +230,7 @@ export class Location { * Returns the path of the current URL. */ getPath() { - return _path; + return this._path; } path(): string; @@ -246,8 +245,8 @@ export class Location { * @returns The `Location` instance. */ setHash(hash: string | number | null) { - validateRequired(hash, "hash"); - _hash = hash !== null ? hash.toString() : ""; + if (isUndefined(hash)) validateRequired(hash, "hash"); + this._hash = hash !== null ? hash.toString() : ""; this._compose(); return this; @@ -258,7 +257,7 @@ export class Location { * @returns The current hash fragment. */ getHash() { - return _hash; + return this._hash; } hash(): string; @@ -283,7 +282,7 @@ export class Location { case 1: if (isString(search) || isNumber(search)) { search = search.toString(); - _search = parseKeyValue(search); + this._search = parseKeyValue(search); } else if (isObject(search)) { const clonedSearch = structuredClone(search); @@ -292,7 +291,7 @@ export class Location { if (isNull(value)) deleteProperty(clonedSearch, key); }); - _search = clonedSearch; + this._search = clonedSearch; } else { throw $locationError( "isrcharg", @@ -311,9 +310,9 @@ export class Location { const searchKey = isString(search) ? search : String(search); if (isUndefined(paramValue) || paramValue === null) { - deleteProperty(_search, searchKey); + deleteProperty(this._search, searchKey); } else { - _search[searchKey] = paramValue; + this._search[searchKey] = paramValue; } break; } @@ -330,7 +329,7 @@ export class Location { * @returns The current search object. */ getSearch(): Record { - return _search; + return this._search; } search(): Record; @@ -353,11 +352,11 @@ export class Location { * Compose url and update `url` and `absUrl` property */ _compose(): void { - this._url = normalizePath(_path, _search, _hash); + this._url = normalizePath(this._path, this._search, this._hash); this.absUrl = this.html5 ? this.appBaseNoFile + this._url.substring(1) : this.appBase + (this._url ? (this.hashPrefix ?? "") + this._url : ""); - urlUpdatedByLocation = true; + this._urlUpdatedByLocation = true; setTimeout(() => this._updateBrowser?.()); } @@ -383,7 +382,7 @@ export class Location { // but we're changing the _statereference to $browser.state() during the $digest // so the modification window is narrow. this._state = state; - urlUpdatedByLocation = true; + this._urlUpdatedByLocation = true; return this; } @@ -468,10 +467,14 @@ export class Location { ); } - parseAppUrl(pathUrl, true); + const parsed = parseAppUrl(pathUrl, true); - if (!_path) { - _path = "/"; + this._path = parsed.path; + this._search = parsed.search; + this._hash = parsed.hash; + + if (!this._path) { + this._path = "/"; } this._compose(); @@ -498,16 +501,26 @@ export class Location { } } - parseAppUrl(withoutHashUrl, false); + const parsed = parseAppUrl(withoutHashUrl, false); + + this._path = parsed.path; + this._search = parsed.search; + this._hash = parsed.hash; this._compose(); } } } -export class LocationProvider { - hashPrefixConf: string; - /** @internal */ - _html5ModeConf: Html5Mode; +/** + * Runtime-owned location policy and browser history state. + * + * @internal + */ +export class LocationRuntimeState { + readonly config: { + html5Mode: Html5Mode; + hashPrefix: string; + }; /** @internal */ _urlChangeListeners: UrlChangeListener[]; /** @internal */ @@ -522,14 +535,22 @@ export class LocationProvider { _urlChangeHandler?: EventListener; /** @internal */ _rootClickHandler?: EventListener; + /** @internal */ + _serviceCleanup?: () => void; lastCachedState: unknown; - - constructor() { - this.hashPrefixConf = "!"; - this._html5ModeConf = { - enabled: true, - requireBase: false, - rewriteLinks: true, + /** @internal */ + _destroyed: boolean; + /** @internal */ + readonly _window: Window; + + constructor(browserWindow: Window) { + this.config = { + hashPrefix: "!", + html5Mode: { + enabled: true, + requireBase: false, + rewriteLinks: true, + }, }; this._urlChangeListeners = []; @@ -538,18 +559,12 @@ export class LocationProvider { this._cachedState = null; this._lastHistoryState = null; - this._lastBrowserUrl = window.location.href; + this._destroyed = false; + this._window = browserWindow; + this._lastBrowserUrl = browserWindow.location.href; this.cacheState(); } - get html5ModeConf(): Html5Mode { - return this._html5ModeConf; - } - - set html5ModeConf(value: Html5Mode) { - this._html5ModeConf = value; - } - /// /////////////////////////////////////////////////////////// // URL API /// /////////////////////////////////////////////////////////// @@ -559,7 +574,7 @@ export class LocationProvider { * * @param url - The target URL to navigate to. * @param [state=null] - Optional history state object to associate with the new URL. - * @returns The provider instance. + * @returns The runtime state. */ setUrl(url: string | undefined, state?: unknown) { if (state === undefined) { @@ -575,7 +590,7 @@ export class LocationProvider { this._lastBrowserUrl = url; this._lastHistoryState = state; - history.pushState(state, "", url); + this._window.history.pushState(state, "", url); this.cacheState(); } @@ -588,7 +603,7 @@ export class LocationProvider { * @returns The normalized browser URL. */ getBrowserUrl() { - return trimEmptyHash(window.location.href); + return trimEmptyHash(this._window.location.href); } /** @@ -606,7 +621,7 @@ export class LocationProvider { * @private */ cacheState() { - const currentState: unknown = history.state ?? null; + const currentState: unknown = this._window.history.state ?? null; if (!equals(currentState, this.lastCachedState)) { this._cachedState = currentState; @@ -633,7 +648,7 @@ export class LocationProvider { this._lastBrowserUrl = this.getBrowserUrl(); this._lastHistoryState = this._cachedState; this._urlChangeListeners.forEach((listener: UrlChangeListener) => { - listener(trimEmptyHash(window.location.href), this._cachedState); + listener(trimEmptyHash(this._window.location.href), this._cachedState); }); } @@ -644,305 +659,350 @@ export class LocationProvider { * @param callback - Listener invoked with the new URL and history state. */ _onUrlChange(callback: UrlChangeListener): void { + this._assertActive(); + if (!this._urlChangeInit) { this._urlChangeHandler ??= this._fireStateOrUrlChange.bind(this); - window.addEventListener("popstate", this._urlChangeHandler); - window.addEventListener("hashchange", this._urlChangeHandler); + this._window.addEventListener("popstate", this._urlChangeHandler); + this._window.addEventListener("hashchange", this._urlChangeHandler); this._urlChangeInit = true; } this._urlChangeListeners.push(callback); } - $get = [ - _rootScope, - _rootElement, - _exceptionHandler, - ( - $rootScope: ng.Scope, - $rootElement: HTMLElement, - $exceptionHandler: ng.ExceptionHandlerService, - ) => { - const baseHref = getBaseHref(); // if base[href] is undefined, it defaults to '' + /** @internal */ + createService( + $rootScope: ng.Scope, + $rootElement: HTMLElement, + $exceptionHandler: ng.ExceptionHandlerService, + ): Location { + this._assertActive(); + const baseHref = getBaseHref(); // if base[href] is undefined, it defaults to '' - const initialUrl = trimEmptyHash(window.location.href); + const initialUrl = trimEmptyHash(this._window.location.href); - let appBase; + let appBase; - if (this.html5ModeConf.enabled) { - if (!baseHref && this.html5ModeConf.requireBase) { - throw $locationError( - "nobase", - "$location in HTML5 mode requires a tag to be present!", - ); - } - appBase = serverBase(initialUrl) + (baseHref || "/"); - } else { - appBase = stripHash(initialUrl); + if (this.config.html5Mode.enabled) { + if (!baseHref && this.config.html5Mode.requireBase) { + throw $locationError( + "nobase", + "$location in HTML5 mode requires a tag to be present!", + ); } - const appBaseNoFile = stripFile(appBase); + appBase = serverBase(initialUrl) + (baseHref || "/"); + } else { + appBase = stripHash(initialUrl); + } + const appBaseNoFile = stripFile(appBase); - const $location = new Location( - appBase, - appBaseNoFile, - this.html5ModeConf.enabled, - `#${this.hashPrefixConf}`, - ); + const $location = new Location( + appBase, + appBaseNoFile, + this.config.html5Mode.enabled, + `#${this.config.hashPrefix}`, + ); - $location.parseLinkUrl(initialUrl, initialUrl); + $location.parseLinkUrl(initialUrl, initialUrl); - $location._state = this.state(); + $location._state = this.state(); - let destroyed = false; + let destroyed = false; - const IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; + const IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; - locationCleanupByRootElement.get($rootElement)?.(); + locationCleanupByRootElement.get($rootElement)?.(); - const setBrowserUrlWithFallback = ( - url: string | undefined, - state: unknown, - ) => { - const oldUrl = $location.getUrl(); + const setBrowserUrlWithFallback = ( + url: string | undefined, + state: unknown, + ) => { + const oldUrl = this.getBrowserUrl(); + + const oldState: unknown = $location._state; + + try { + this.setUrl(url, state); + + // Make sure $location.getState() returns referentially identical (not just deeply equal) + // state object; this makes possible quick checking if the state changed in the digest + // loop. Checking deep equality would be too expensive. + $location._state = this.state(); + } catch (err) { + // Restore old values if pushState fails + $location.parse(oldUrl); + $location._state = oldState; + $exceptionHandler(err); + } + }; - const oldState: unknown = $location._state; + const broadcastRootScopeEvent = ( + name: string, + ...args: unknown[] + ): ng.ScopeEvent => $rootScope.$broadcast(name, ...args); + + const clickHandler = ((event: MouseEvent) => { + const { rewriteLinks } = this.config.html5Mode; + // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) + // currently we open nice url link and redirect then + + if ( + !isLinkRewritingEnabled(rewriteLinks) || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.button === 2 + ) { + return; + } + let elm = event.target as HTMLElement; - try { - this.setUrl(url, state); + // traverse the DOM up to find first A tag + while (elm.nodeName.toLowerCase() !== "a") { + // ignore rewriting if no A tag (reached root element, or no parent - removed from document) - // Make sure $location.getState() returns referentially identical (not just deeply equal) - // state object; this makes possible quick checking if the state changed in the digest - // loop. Checking deep equality would be too expensive. - $location._state = this.state(); - } catch (err) { - // Restore old values if pushState fails - $location.setUrl(oldUrl); - $location._state = oldState; - $exceptionHandler(err); - } - }; + if (elm === $rootElement || !elm.parentElement) return; + + elm = elm.parentElement; + } - const broadcastRootScopeEvent = ( - name: string, - ...args: unknown[] - ): ng.ScopeEvent | undefined => { - const broadcast = ($rootScope as unknown as { $broadcast?: unknown }) - .$broadcast; + if (isString(rewriteLinks) && !elm.hasAttribute(rewriteLinks)) { + return; + } - if (!isFunction(broadcast)) return undefined; + let absHref = (elm as HTMLAnchorElement).href as + | string + | SVGAnimatedString; - return callFunction(broadcast, $rootScope, name, ...args) as - | ng.ScopeEvent - | undefined; - }; + const relHref = elm.getAttribute("href"); - const clickHandler = ((event: MouseEvent) => { - const { rewriteLinks } = this.html5ModeConf; - // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) - // currently we open nice url link and redirect then + if (!isString(absHref) && "animVal" in absHref) { + // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during + // an animation. + absHref = new URL(absHref.animVal).href; + } - if ( - !isLinkRewritingEnabled(rewriteLinks) || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.button === 2 - ) { - return; + // Ignore when url is started with javascript: or mailto: + if (IGNORE_URI_REGEXP.test(absHref)) return; + + if (absHref && !elm.getAttribute("target") && !event.defaultPrevented) { + if ($location.parseLinkUrl(absHref, relHref)) { + // We do a preventDefault for all urls that are part of the AngularTS application, + // in html5mode and also without, so that we are able to abort navigation without + // getting double entries in the location history. + event.preventDefault(); } - let elm = event.target as HTMLElement | null; + } + }) as EventListener; - if (!elm) return; + this._rootClickHandler = clickHandler; + $rootElement.addEventListener("click", clickHandler); - // traverse the DOM up to find first A tag - while (elm.nodeName.toLowerCase() !== "a") { - // ignore rewriting if no A tag (reached root element, or no parent - removed from document) + const cleanupLocation = () => { + destroyed = true; - if (elm === $rootElement || !(elm = elm.parentElement)) return; - } + if (this._rootClickHandler) { + $rootElement.removeEventListener("click", this._rootClickHandler); + this._rootClickHandler = undefined; + } - if ( - isString(rewriteLinks) && - isUndefined(elm.getAttribute(rewriteLinks)) - ) { - return; - } + if (this._urlChangeHandler) { + this._window.removeEventListener("popstate", this._urlChangeHandler); + this._window.removeEventListener("hashchange", this._urlChangeHandler); + this._urlChangeHandler = undefined; + } - let absHref = (elm as HTMLAnchorElement).href as - | string - | SVGAnimatedString; + this._urlChangeInit = false; + this._urlChangeListeners.length = 0; - const relHref = elm.getAttribute("href"); + if (this._serviceCleanup === cleanupLocation) { + this._serviceCleanup = undefined; + } - if (!isString(absHref) && "animVal" in absHref) { - // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during - // an animation. - absHref = new URL(absHref.animVal).href; - } + if (locationCleanupByRootElement.get($rootElement) === cleanupLocation) { + locationCleanupByRootElement.delete($rootElement); + } + }; - // Ignore when url is started with javascript: or mailto: - if (IGNORE_URI_REGEXP.test(absHref)) return; + this._serviceCleanup = cleanupLocation; + locationCleanupByRootElement.set($rootElement, cleanupLocation); + $rootScope.$on("$destroy", () => { + cleanupLocation(); + }); - if (absHref && !elm.getAttribute("target") && !event.defaultPrevented) { - if ($location.parseLinkUrl(absHref, relHref)) { - // We do a preventDefault for all urls that are part of the AngularTS application, - // in html5mode and also without, so that we are able to abort navigation without - // getting double entries in the location history. - event.preventDefault(); - } - } - }) as EventListener; + // rewrite hashbang url <> html5 url + if ($location.absUrl !== initialUrl) { + this.setUrl($location.absUrl, true); + } - this._rootClickHandler = clickHandler; - $rootElement.addEventListener("click", clickHandler); + let initializing = true; - const cleanupLocation = () => { - if (this._rootClickHandler) { - $rootElement.removeEventListener("click", this._rootClickHandler); - this._rootClickHandler = undefined; - } + // update $location when $browser url changes + this._onUrlChange((newUrl: string, newState: unknown) => { + if (!startsWith(newUrl, appBaseNoFile)) { + // If we are navigating outside of the app then force a reload + this._window.location.href = newUrl; - if (this._urlChangeHandler) { - window.removeEventListener("popstate", this._urlChangeHandler); - window.removeEventListener("hashchange", this._urlChangeHandler); - this._urlChangeHandler = undefined; - } + return; + } - this._urlChangeInit = false; - this._urlChangeListeners.length = 0; - }; + queueMicrotask(() => { + if (destroyed) return; - locationCleanupByRootElement.set($rootElement, cleanupLocation); - $rootScope.$on("$destroy", () => { - destroyed = true; - cleanupLocation(); + const oldUrl = $location.absUrl; - if ( - locationCleanupByRootElement.get($rootElement) === cleanupLocation - ) { - locationCleanupByRootElement.delete($rootElement); - } - }); + const oldState: unknown = $location._state; - // rewrite hashbang url <> html5 url - if ($location.absUrl !== initialUrl) { - this.setUrl($location.absUrl, true); - } + $location.parse(newUrl); + $location._state = newState; - let initializing = true; + const { defaultPrevented } = broadcastRootScopeEvent( + "$locationChangeStart", + newUrl, + oldUrl, + newState, + oldState, + ); - // update $location when $browser url changes - this._onUrlChange((newUrl: string, newState: unknown) => { - if (!startsWith(newUrl, appBaseNoFile)) { - // If we are navigating outside of the app then force a reload - window.location.href = newUrl; + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if ($location.absUrl !== newUrl) return; - return; + if (defaultPrevented) { + $location.parse(oldUrl); + $location._state = oldState; + setBrowserUrlWithFallback(oldUrl, oldState); + } else { + initializing = false; + afterLocationChange(oldUrl, oldState); } - - queueMicrotask(() => { - if (destroyed) return; - - const oldUrl = $location.absUrl; - - const oldState: unknown = $location._state; - - $location.parse(newUrl); - $location._state = newState; - - const { defaultPrevented } = broadcastRootScopeEvent( - "$locationChangeStart", - newUrl, - oldUrl, - newState, - oldState, - ) ?? { defaultPrevented: false }; - - // if the location was changed by a `$locationChangeStart` handler then stop - // processing this location change - if ($location.absUrl !== newUrl) return; - - if (defaultPrevented) { - $location.parse(oldUrl); - $location._state = oldState; - setBrowserUrlWithFallback(oldUrl, oldState); - } else { - initializing = false; - afterLocationChange(oldUrl, oldState); - } - }); }); + }); - // update browser - const updateBrowser = () => { - if (initializing || urlUpdatedByLocation) { - urlUpdatedByLocation = false; + // update browser + const updateBrowser = () => { + if (initializing || $location._urlUpdatedByLocation) { + $location._urlUpdatedByLocation = false; - const oldUrl = this.getBrowserUrl(); + const oldUrl = this.getBrowserUrl(); - let newUrl = $location.absUrl; + let newUrl = $location.absUrl; - const oldState: unknown = this.state(); + const oldState: unknown = this.state(); - const urlOrStateChanged = - !urlsEqual(oldUrl, newUrl) || - ($location.html5 && oldState !== $location._state); + const urlOrStateChanged = + !urlsEqual(oldUrl, newUrl) || + ($location.html5 && oldState !== $location._state); - if (initializing || urlOrStateChanged) { - initializing = false; + if (initializing || urlOrStateChanged) { + initializing = false; - setTimeout(() => { - if (destroyed) return; + setTimeout(() => { + if (destroyed) return; - newUrl = $location.absUrl; + newUrl = $location.absUrl; - const { defaultPrevented } = broadcastRootScopeEvent( - "$locationChangeStart", - $location.absUrl, - oldUrl, - $location._state, - oldState, - ) ?? { defaultPrevented: false }; + const { defaultPrevented } = broadcastRootScopeEvent( + "$locationChangeStart", + $location.absUrl, + oldUrl, + $location._state, + oldState, + ); - // if the location was changed by a `$locationChangeStart` handler then stop - // processing this location change - if ($location.absUrl !== newUrl) return; + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if ($location.absUrl !== newUrl) return; - if (defaultPrevented) { - $location.parse(oldUrl); - $location._state = oldState; - } else { - if (urlOrStateChanged) { - setBrowserUrlWithFallback( - newUrl, - oldState === $location._state ? null : $location._state, - ); - } - afterLocationChange(oldUrl, oldState); + if (defaultPrevented) { + $location.parse(oldUrl); + $location._state = oldState; + } else { + if (urlOrStateChanged) { + setBrowserUrlWithFallback( + newUrl, + oldState === $location._state ? null : $location._state, + ); } - }); - } + afterLocationChange(oldUrl, oldState); + } + }); } - }; + } + }; - $location._updateBrowser = updateBrowser; - updateBrowser(); - $rootScope.$on("$updateBrowser", updateBrowser); + $location._updateBrowser = updateBrowser; + updateBrowser(); + $rootScope.$on("$updateBrowser", updateBrowser); - return $location; + return $location; - function afterLocationChange(oldUrl: string, oldState: unknown) { - if (destroyed) return; + function afterLocationChange(oldUrl: string, oldState: unknown) { + if (destroyed) return; - broadcastRootScopeEvent( - "$locationChangeSuccess", - $location.absUrl, - oldUrl, - $location._state, - oldState, - ); - } - }, - ]; + broadcastRootScopeEvent( + "$locationChangeSuccess", + $location.absUrl, + oldUrl, + $location._state, + oldState, + ); + } + } + + /** @internal */ + destroy(): void { + if (this._destroyed) return; + + this._destroyed = true; + this._serviceCleanup?.(); + this._serviceCleanup = undefined; + + if (this._urlChangeHandler) { + this._window.removeEventListener("popstate", this._urlChangeHandler); + this._window.removeEventListener("hashchange", this._urlChangeHandler); + this._urlChangeHandler = undefined; + } + + this._urlChangeInit = false; + this._urlChangeListeners.length = 0; + this._rootClickHandler = undefined; + } + + /** @internal */ + _assertActive(): void { + if (this._destroyed) { + throw new Error("Location runtime has already been disposed."); + } + } +} + +/** @internal */ +export function createLocationRuntimeState( + browserWindow: Window, +): LocationRuntimeState { + return new LocationRuntimeState(browserWindow); +} + +/** @internal */ +export function applyLocationConfiguration( + state: LocationRuntimeState, + config: LocationConfig, +): void { + state._assertActive(); + + if (config.html5Mode !== undefined) { + Object.assign( + state.config.html5Mode, + typeof config.html5Mode === "boolean" + ? { enabled: config.html5Mode } + : config.html5Mode, + ); + } + + if (config.hashPrefix !== undefined) { + state.config.hashPrefix = config.hashPrefix; + } } /** @@ -1066,13 +1126,16 @@ export function normalizePath( /** * @ignore - * Parses the application URL and updates the location object with path, search, and hash. + * Parses an application URL into isolated path, search, and hash values. * * @param url - The URL string to parse. * @param html5Mode - Whether HTML5 mode is enabled (affects decoding). * @throws Will throw an error if the URL starts with invalid slashes. */ -export function parseAppUrl(url: string, html5Mode: boolean): void { +export function parseAppUrl( + url: string, + html5Mode: boolean, +): { path: string; search: Record; hash: string } { if (/^\s*[\\/]{2,}/.test(url)) { throw $locationError("badpath", 'Invalid url "{0}".', url); } @@ -1089,14 +1152,18 @@ export function parseAppUrl(url: string, html5Mode: boolean): void { ? match.pathname.substring(1) : match.pathname; - _path = decodePath(path, html5Mode); - _search = parseKeyValue(match.search); - _hash = decodeURIComponent(match.hash); + let parsedPath = decodePath(path, html5Mode); // make sure path starts with '/'; - if (_path && !_path.startsWith("/")) { - _path = `/${_path}`; + if (parsedPath && !parsedPath.startsWith("/")) { + parsedPath = `/${parsedPath}`; } + + return { + path: parsedPath, + search: parseKeyValue(match.search), + hash: decodeURIComponent(match.hash), + }; } /** diff --git a/src/services/log/README.md b/src/services/log/README.md new file mode 100644 index 000000000..3ecf6d802 --- /dev/null +++ b/src/services/log/README.md @@ -0,0 +1,126 @@ +# Log Internals + +This directory owns the injectable `$log` facade. `log.ts` keeps logging policy +in a plain runtime configuration record and constructs either a console-backed +service or an application-supplied `LogService` lazily. + +## Responsibilities + +- Expose `log`, `info`, `warn`, `error`, and `debug` methods through DI. +- Normalize `Error` objects before writing them to the browser console. +- Suppress default debug output unless typed configuration enables it. +- Allow applications to replace the complete logging implementation. +- Optionally mirror selected levels to `navigator.sendBeacon()`. + +## Public Surface + +- `LogService`: named contract for the `$log` injectable. +- `LogConfig`: configuration accepted by `module.config({ $log: ... })`. +- `LogBeaconConfig`: opt-in Beacon endpoint, level, serializer, and failure + policy. +- `LogEntry`: structured record passed to remote serializers. +- `LogBeaconSerializer`: application-owned conversion to `BodyInit`. +- `LogLevel`: supported logging severity names. +- `LogServiceFactory`: factory for an application-owned logger. +- `LogCall`: common logging method signature. + +```ts +app.config({ + $log: { + debug: true, + beacon: { + url: "/api/client-logs", + levels: ["warn", "error"], + serializer: "clientLogSerializer", + }, + }, +}); +``` + +## Core Model + +Runtime composition owns one mutable configuration record. Module config +blocks merge into that record before the injector constructs `$log`. The lazy +factory either invokes the configured logger factory or binds methods from the +runtime window's console. + +Important invariants: + +- Configuration does not require a provider token. +- Partial config calls preserve fields set by earlier calls. +- A custom logger owns all of its local method behavior, including debug + filtering; configured Beacon delivery composes around it. +- The default implementation preserves the console receiver when invoking + native methods. +- Beacon configuration is opt-in and defaults to sending only `error` entries. +- A named serializer is resolved through the application injector when `$log` + is first constructed. + +## Lifecycle Contract + +- Configuration is created when browser providers are composed. +- The logger service is created on first injection. +- `$log` owns no subscriptions or persistent native resources, so it requires + no service-specific disposer. The browser owns each successfully queued + Beacon request. + +## Reactivity Contract + +Logging has no reactive state. Calls are synchronous and do not schedule scope +updates. Applications that need observable diagnostics should publish explicit +models or events from their custom logger. + +## Policy Contract + +- `debug` defaults to `false` for the console-backed service. +- `logger` replaces the local implementation while preserving configured + Beacon delivery. +- `beacon: false` disables Beacon configuration inherited from an earlier + module configuration call. +- Beacon serialization and queueing failures warn locally by default; + `failure: "ignore"` suppresses that warning. +- Policy is merged during module configuration before lazy construction. + +## Dependency Replacement Contract + +`$log` removes direct console coupling from framework and application code and +provides one replacement point for telemetry integrations. Beacon delivery is +one request per selected call. It intentionally does not provide buffering, +persistence, transport retries, redaction, acknowledgement, or log storage. +Applications must use a named serializer when remote payloads require secret +removal or application metadata. + +## Composition Contract + +- Browser runtime composition supplies the native console. +- `$exceptionHandler`, directives, and services consume the public `$log` + contract through DI. +- Logging does not depend on scopes, the DOM compiler, router, machine, or + workflow modules. + +## Failure Contract + +- Logger exceptions propagate synchronously to the caller. +- A custom factory must return a complete `LogService` implementation. +- The default logger falls back to `console.log` when a requested console + method is unavailable. +- Beacon failures never interrupt the logging caller. They follow the + configured `warn` or `ignore` failure policy. + +## Scheduling And Ordering + +Local logging calls execute synchronously in call order. `$log` invokes +`sendBeacon()` after local output and does not batch, retry, or reorder entries. + +## Native Interop + +The default implementation wraps the runtime window's `Console`. Optional +remote delivery binds the runtime window's `Navigator.sendBeacon`. Applications +requiring another sink configure a `LogServiceFactory` or decorate `$log`. + +## Test Harness + +- `log.spec.ts` verifies injection, console delegation, debug gating, error + formatting, config merging, custom logger replacement, Beacon level + filtering, injectable serialization, and failure containment. +- `log.test.ts` executes the suite in a browser through Playwright. diff --git a/src/services/log/log.spec.ts b/src/services/log/log.spec.ts index 5bbbfa49b..4069ef30d 100644 --- a/src/services/log/log.spec.ts +++ b/src/services/log/log.spec.ts @@ -1,77 +1,383 @@ /// -import { createInjector } from "../../core/di/injector.ts"; import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; +import { + applyLogConfiguration, + createLogRuntimeConfiguration, + createLogService, +} from "./log.ts"; -describe("$logService", () => { - let $logService: any, - logProvider: any, - el: any, - log: any = [], - angular: any; +describe("$log", () => { + let angular: Angular; + let element: HTMLElement; beforeEach(() => { - el = document.getElementById("app"); - el.innerHTML = ""; + element = document.getElementById("app") as HTMLElement; + element.innerHTML = ""; angular = new Angular(); + }); + + afterEach(() => { + dealoc(element); + }); + + function bootstrap(moduleName: string): ng.LogService { + return angular.bootstrap(element, [moduleName]).get("$log"); + } + + it("is injectable", () => { + angular.module("default", []); + + const log = bootstrap("default"); + + expect(log).toBeDefined(); + expect(typeof log.debug).toBe("function"); + }); + + it("delegates errors to the console by default", () => { + angular.module("default", []); + const consoleError = spyOn(window.console, "error"); + + bootstrap("default").error("error message"); + + expect(consoleError).toHaveBeenCalledOnceWith("error message"); + }); + + it("formats Error objects before logging them", () => { + angular.module("default", []); + const consoleError = spyOn(window.console, "error"); + const error = new Error("broken"); + + error.stack = "stack trace"; + bootstrap("default").error(error); + + expect(consoleError).toHaveBeenCalledOnceWith("Error: broken\nstack trace"); + }); + + it("suppresses debug logging by default", () => { + angular.module("default", []); + const consoleDebug = spyOn(window.console, "debug"); + + bootstrap("default").debug("details"); + + expect(consoleDebug).not.toHaveBeenCalled(); + }); - angular.module("default", []).config(($logProvider: any) => { - logProvider = $logProvider; + it("enables debug logging through typed object configuration", () => { + angular.module("debug", []).config({ $log: { debug: true } }); + const consoleDebug = spyOn(window.console, "debug"); + + bootstrap("debug").debug("details"); + + expect(consoleDebug).toHaveBeenCalledOnceWith("details"); + }); + + it("replaces the console implementation through typed configuration", () => { + const customLog = jasmine.createSpy("customLog"); + + angular.module("custom", []).config({ + $log: { + logger: () => ({ + log: customLog, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + }), + }, + }); + + bootstrap("custom").log("configured"); + + expect(customLog).toHaveBeenCalledOnceWith("configured"); + }); + + it("merges partial logging configuration", () => { + const configuration = createLogRuntimeConfiguration(); + const levels = ["warn"] as const; + const logger = () => ({ + log: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, }); - angular.bootstrap(el, ["default"]).invoke((_$log_: any) => { - $logService = _$log_; + + applyLogConfiguration(configuration, { + beacon: { url: "/logs", levels }, + debug: true, + }); + applyLogConfiguration(configuration, { logger }); + + expect(configuration).toEqual({ + beacon: { url: "/logs", levels: ["warn"] }, + debug: true, + logger, + }); + expect(configuration.beacon?.levels).not.toBe(levels); + }); + + it("disables inherited Beacon configuration", () => { + const configuration = createLogRuntimeConfiguration(); + + applyLogConfiguration(configuration, { beacon: { url: "/logs" } }); + applyLogConfiguration(configuration, { beacon: false }); + + expect(configuration.beacon).toBeUndefined(); + }); + + it("sends error entries through Beacon without replacing console output", async () => { + const configuration = createLogRuntimeConfiguration(); + const consoleError = jasmine.createSpy("consoleError"); + const sendBeacon = jasmine.createSpy("sendBeacon").and.returnValue(true); + const consoleRef = { + error: consoleError, + log: () => undefined, + warn: () => undefined, + } as unknown as Console; + + applyLogConfiguration(configuration, { beacon: { url: "/logs" } }); + const log = createLogService(configuration, consoleRef, { + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + sendBeacon, }); + const circular: { self?: unknown } = {}; + + circular.self = circular; + + log.info("not remote"); + log.error("broken", new Error("failure"), 1n, circular); - window.console.error = (msg) => { - log.push(msg); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(sendBeacon).toHaveBeenCalledTimes(1); + expect(sendBeacon.calls.mostRecent().args[0]).toBe("/logs"); + + const body = sendBeacon.calls.mostRecent().args[1] as Blob; + const entry = JSON.parse(await body.text()) as { + args: unknown[]; + level: string; + timestamp: string; }; + + expect(body.type).toBe("application/json"); + expect(entry.level).toBe("error"); + expect(entry.timestamp).toBe("2026-07-14T12:00:00.000Z"); + expect(entry.args[0]).toBe("broken"); + expect(entry.args[1]).toContain("failure"); + expect(entry.args[2]).toBe("1"); + expect(entry.args[3]).toEqual({ self: "[Circular]" }); }); - afterEach(() => { - dealoc(el); + it("resolves a configured Beacon serializer through dependency injection", () => { + const serialize = jasmine + .createSpy("serialize") + .and.callFake((entry: ng.LogEntry) => entry.level); + const sendBeacon = spyOn(window.navigator, "sendBeacon").and.returnValue( + true, + ); + + angular + .module("beaconSerializer", []) + .factory("clientLogSerializer", () => serialize) + .config({ + $log: { + beacon: { + levels: ["warn"], + serializer: "clientLogSerializer", + url: "/client-logs", + }, + }, + }); + + bootstrap("beaconSerializer").warn("warning"); + + expect(serialize).toHaveBeenCalledTimes(1); + expect(serialize.calls.mostRecent().args[0].args).toEqual(["warning"]); + expect(sendBeacon).toHaveBeenCalledOnceWith("/client-logs", "warn"); }); - it("should be available as a provider", () => { - expect(logProvider).toBeDefined(); - expect(logProvider.debug).toBeFalse(); - expect(typeof logProvider.$get).toBe("function"); + it("warns locally when Beacon rejects a payload", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleWarn = jasmine.createSpy("consoleWarn"); + + applyLogConfiguration(configuration, { beacon: { url: "/logs" } }); + createLogService( + configuration, + { + error: () => undefined, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console, + { sendBeacon: () => false }, + ).error("broken"); + + expect(consoleWarn).toHaveBeenCalledWith( + "$log Beacon delivery failed: the browser rejected the payload", + ); }); - it("should be injectable", () => { - expect($logService).toBeDefined(); - expect(typeof $logService.debug).toBe("function"); + it("can ignore Beacon failures", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleWarn = jasmine.createSpy("consoleWarn"); + + applyLogConfiguration(configuration, { + beacon: { failure: "ignore", url: "/logs" }, + }); + createLogService( + configuration, + { + error: () => undefined, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console, + { sendBeacon: () => false }, + ).error("broken"); + + expect(consoleWarn).not.toHaveBeenCalled(); }); - it("should call console.error by default when $log.error is called", () => { - $logService.error("error message"); - expect(log[0]).toEqual("error message"); + it("reports an unavailable Beacon implementation only once", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleWarn = jasmine.createSpy("consoleWarn"); + + applyLogConfiguration(configuration, { beacon: { url: "/logs" } }); + const log = createLogService(configuration, { + error: () => undefined, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console); + + log.error("first"); + log.error("second"); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + "$log Beacon delivery failed: navigator.sendBeacon() is unavailable", + ); }); - it("can be overriden", () => { - let called = false; + it("contains serializer failures without interrupting logging", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleError = jasmine.createSpy("consoleError"); + const consoleWarn = jasmine.createSpy("consoleWarn"); + const failure = new Error("serializer failed"); - angular.module("default2", []).config(($logProvider: any) => { - $logProvider.setLogger(() => ({ - log: () => (called = true), - info: () => { - /* empty */ - }, - warn: () => { - /* empty */ - }, - error: () => { - /* empty */ - }, - debug: () => { - /* empty */ + applyLogConfiguration(configuration, { + beacon: { serializer: "brokenSerializer", url: "/logs" }, + }); + const log = createLogService( + configuration, + { + error: consoleError, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console, + { + resolveSerializer: () => () => { + throw failure; }, - })); + sendBeacon: () => true, + }, + ); + + expect(() => log.error("broken")).not.toThrow(); + expect(consoleError).toHaveBeenCalledOnceWith("broken"); + expect(consoleWarn).toHaveBeenCalledWith( + "$log Beacon delivery failed: serialization or queueing threw", + jasmine.stringContaining("serializer failed"), + ); + }); + + it("composes Beacon delivery around a custom logger", () => { + const configuration = createLogRuntimeConfiguration(); + const customError = jasmine.createSpy("customError"); + const sendBeacon = jasmine.createSpy("sendBeacon").and.returnValue(true); + + applyLogConfiguration(configuration, { + beacon: { url: "/logs" }, + logger: () => ({ + debug: () => undefined, + error: customError, + info: () => undefined, + log: () => undefined, + warn: () => undefined, + }), + }); + createLogService( + configuration, + { log: () => undefined, warn: () => undefined } as unknown as Console, + { sendBeacon }, + ).error("broken"); + + expect(customError).toHaveBeenCalledOnceWith("broken"); + expect(sendBeacon).toHaveBeenCalledTimes(1); + }); + + it("reports a serializer that cannot be resolved", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleWarn = jasmine.createSpy("consoleWarn"); + + applyLogConfiguration(configuration, { + beacon: { serializer: "missingSerializer", url: "/logs" }, }); + createLogService( + configuration, + { + error: () => undefined, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console, + { sendBeacon: () => true }, + ).error("broken"); + + expect(consoleWarn).toHaveBeenCalledWith( + '$log Beacon delivery failed: serializer "missingSerializer" could not be resolved', + ); + }); + + it("contains injectable serializer resolution failures", () => { + const configuration = createLogRuntimeConfiguration(); + const consoleWarn = jasmine.createSpy("consoleWarn"); + const failure = new Error("unknown serializer"); + + applyLogConfiguration(configuration, { + beacon: { serializer: "missingSerializer", url: "/logs" }, + }); + const log = createLogService( + configuration, + { + error: () => undefined, + log: () => undefined, + warn: consoleWarn, + } as unknown as Console, + { + resolveSerializer: () => { + throw failure; + }, + sendBeacon: () => true, + }, + ); + + expect(() => log.error("first")).not.toThrow(); + log.error("second"); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + '$log Beacon delivery failed: serializer "missingSerializer" could not be resolved', + jasmine.stringContaining("unknown serializer"), + ); + }); + + it("falls back to console.log when a console method is unavailable", () => { + const fallback = jasmine.createSpy("fallback"); + const consoleRef = { + log: fallback, + } as unknown as Console; - const $injector = createInjector(["ng", "default2"]); + createLogService(createLogRuntimeConfiguration(), consoleRef).warn( + "warning", + ); - expect($injector).toBeDefined(); - ($injector.get("$log") as ng.LogService).log(); - expect(called).toBeTrue(); + expect(fallback).toHaveBeenCalledOnceWith("warning"); }); }); diff --git a/src/services/log/log.ts b/src/services/log/log.ts index b751f488c..a0bd73125 100644 --- a/src/services/log/log.ts +++ b/src/services/log/log.ts @@ -5,6 +5,34 @@ import { isError } from "../../shared/utils.ts"; */ export type LogCall = (...args: unknown[]) => void; +/** Logging severity attached to a structured remote log entry. */ +export type LogLevel = "debug" | "error" | "info" | "log" | "warn"; + +/** Structured record passed to a configured Beacon serializer. */ +export interface LogEntry { + /** Arguments originally passed to the logging method. */ + readonly args: readonly unknown[]; + /** Logging method that produced this entry. */ + readonly level: LogLevel; + /** ISO-8601 timestamp captured when the logging method was called. */ + readonly timestamp: string; +} + +/** Converts a structured log entry into a Beacon-compatible request body. */ +export type LogBeaconSerializer = (entry: LogEntry) => BodyInit; + +/** Declarative remote logging configuration for `navigator.sendBeacon()`. */ +export interface LogBeaconConfig { + /** Action taken when serialization or Beacon queueing fails. */ + failure?: "ignore" | "warn"; + /** Levels delivered remotely. Defaults to `error` only. */ + levels?: readonly LogLevel[]; + /** Name of an injectable {@link LogBeaconSerializer}. */ + serializer?: string; + /** Beacon endpoint URL. */ + url: string; +} + /** * Service for logging messages at various levels. */ @@ -41,83 +69,216 @@ export interface LogService { export type LogServiceFactory = (...args: never[]) => LogService; /** - * Configuration provider for `$log` service + * Declarative configuration accepted by `NgModule.config({ $log: ... })`. */ -export class LogProvider { +export interface LogConfig { + /** Configure remote Beacon delivery, or disable an inherited configuration. */ + beacon?: LogBeaconConfig | false; + /** Enable or disable debug logging. */ + debug?: boolean; + /** Replace the default console-backed logger. */ + logger?: LogServiceFactory; +} + +/** @internal */ +export interface LogRuntimeConfiguration { + beacon?: LogBeaconConfig; debug: boolean; - /** @internal */ - private _override: LogServiceFactory | null; + logger?: LogServiceFactory; +} + +/** @internal */ +export interface LogRuntimeDependencies { + now?: () => number; + resolveSerializer?: (name: string) => LogBeaconSerializer; + sendBeacon?: (url: string, data: BodyInit) => boolean; +} - /** @private */ - constructor() { - this.debug = false; - this._override = null; +/** @internal */ +export function createLogRuntimeConfiguration(): LogRuntimeConfiguration { + return { debug: false }; +} + +/** @internal */ +export function applyLogConfiguration( + configuration: LogRuntimeConfiguration, + config: LogConfig, +): void { + if (config.beacon !== undefined) { + configuration.beacon = + config.beacon === false + ? undefined + : { + ...config.beacon, + levels: config.beacon.levels + ? [...config.beacon.levels] + : undefined, + }; } - /** - * Override the default {@link LogService} implemenation - */ - setLogger(fn: LogServiceFactory): void { - this._override = fn; + if (config.debug !== undefined) { + configuration.debug = config.debug; } - /** - * @private - * Normalizes `Error` objects into readable log output. - */ - /** @internal */ - private static _formatError(arg: unknown): unknown { - if (isError(arg)) { - if (arg.stack) { - arg = - arg.message && !arg.stack.includes(arg.message) - ? `Error: ${arg.message}\n${arg.stack}` - : arg.stack; - } + if (config.logger !== undefined) { + configuration.logger = config.logger; + } +} + +/** @internal */ +function formatError(arg: unknown): unknown { + if (isError(arg) && arg.stack) { + return arg.message && !arg.stack.includes(arg.message) + ? `Error: ${arg.message}\n${arg.stack}` + : arg.stack; + } + + return arg; +} + +/** @internal */ +function createConsoleLog(consoleRef: Console, type: keyof Console): LogCall { + const candidate = consoleRef[type]; + const selected = typeof candidate === "function" ? candidate : consoleRef.log; + const log = selected.bind(consoleRef) as LogCall; + + return (...args) => { + log(...args.map(formatError)); + }; +} + +/** @internal */ +function defaultBeaconSerializer(entry: LogEntry): BodyInit { + const seen = new WeakSet(); + const json = JSON.stringify(entry, (_key, value: unknown) => { + if (isError(value)) { + return formatError(value); + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); } - return arg; + return value; + }); + + return new Blob([json], { type: "application/json" }); +} + +/** @internal */ +function createBeaconLog( + config: LogBeaconConfig, + consoleRef: Console, + dependencies: LogRuntimeDependencies, +): (level: LogLevel, args: readonly unknown[]) => void { + const levels = new Set(config.levels ?? ["error"]); + const warn = createConsoleLog(consoleRef, "warn"); + let serializer: LogBeaconSerializer | undefined = defaultBeaconSerializer; + let serializerResolutionError: unknown; + let reportedUnavailable = false; + let reportedSerializerFailure = false; + + if (config.serializer) { + try { + serializer = dependencies.resolveSerializer?.(config.serializer); + } catch (error) { + serializer = undefined; + serializerResolutionError = error; + } } - /** - * @private - * Builds a console-backed logger for the requested method name. - */ - /** @internal */ - private static _consoleLog(type: string): LogCall { - const consoleRef = window.console as Console & - Partial>; + const reportFailure = (message: string, error?: unknown): void => { + if ((config.failure ?? "warn") === "ignore") return; - const logFn = - consoleRef[type]?.bind(consoleRef) ?? consoleRef.log.bind(consoleRef); + if (error === undefined) { + warn(`$log Beacon delivery failed: ${message}`); + } else { + warn(`$log Beacon delivery failed: ${message}`, error); + } + }; - return (...args) => { - const formattedArgs = args.map((arg) => LogProvider._formatError(arg)); + return (level, args) => { + if (!levels.has(level)) return; - logFn(...formattedArgs); + if (!dependencies.sendBeacon) { + if (!reportedUnavailable) { + reportedUnavailable = true; + reportFailure("navigator.sendBeacon() is unavailable"); + } + + return; + } + + if (!serializer) { + if (!reportedSerializerFailure) { + reportedSerializerFailure = true; + reportFailure( + `serializer "${String(config.serializer)}" could not be resolved`, + serializerResolutionError, + ); + } + + return; + } + + const entry: LogEntry = { + args, + level, + timestamp: new Date(dependencies.now?.() ?? Date.now()).toISOString(), }; - } - /** Creates the runtime `$log` service. */ - $get(): ng.LogService { - if (this._override) { - return this._override(); + try { + const queued = dependencies.sendBeacon(config.url, serializer(entry)); + + if (!queued) reportFailure("the browser rejected the payload"); + } catch (error) { + reportFailure("serialization or queueing threw", error); } + }; +} + +/** @internal */ +export function createLogService( + configuration: LogRuntimeConfiguration, + consoleRef: Console, + dependencies: LogRuntimeDependencies = {}, +): LogService { + const debug = createConsoleLog(consoleRef, "debug"); + const logger = configuration.logger?.() ?? { + log: createConsoleLog(consoleRef, "log"), + info: createConsoleLog(consoleRef, "info"), + warn: createConsoleLog(consoleRef, "warn"), + error: createConsoleLog(consoleRef, "error"), + debug: (...args: unknown[]) => { + if (configuration.debug) debug(...args); + }, + }; + const beacon = configuration.beacon + ? createBeaconLog(configuration.beacon, consoleRef, dependencies) + : undefined; - return { - log: LogProvider._consoleLog("log"), - info: LogProvider._consoleLog("info"), - warn: LogProvider._consoleLog("warn"), - error: LogProvider._consoleLog("error"), - debug: (() => { - const fn = LogProvider._consoleLog("debug"); - - return (...args: unknown[]) => { - if (this.debug) { - fn(...args); - } - }; - })(), + if (!beacon) return logger; + + const wrap = (level: LogLevel): LogCall => { + return (...args) => { + try { + logger[level](...args); + } finally { + beacon(level, args); + } }; - } + }; + + return { + debug: wrap("debug"), + error: wrap("error"), + info: wrap("info"), + log: wrap("log"), + warn: wrap("warn"), + }; } diff --git a/src/services/machine/README.md b/src/services/machine/README.md index a0936b062..13f13e0c9 100644 --- a/src/services/machine/README.md +++ b/src/services/machine/README.md @@ -2,34 +2,72 @@ This directory owns AngularTS reactive mode machines for declarative UI and application flows. The implementation in `machine.ts` is centered on a small -stateful target object that can register with scope proxies when observed, +runtime target object that can register with scope proxies when observed, without tying the machine lifetime to any one scope. +The public machine API uses a declarative state-tree and event contract. + ## Responsibilities -- Create reactive machines from `$machine(config)` and `$machine(scope, config)`. +- Create reactive machines from `$machine(config)`. - Expose mode transitions through `send()`, guarded `can()`, and `matches()`. - Keep `current` and `data` observable when a scope proxy wraps a machine. - Coalesce transition updates through the active scope's `$batch()` scheduler. - Preserve machine instances across destroyed observing scopes. -- Snapshot and restore machine state without restoring transition functions or - hooks. +- Snapshot and restore durable mode and data without restoring transition + functions or hooks. - Run mode-specific and global transition hooks in deterministic order. ## Public Surface -- `MachineProvider`: registers `$machine` as an injectable service. - `defineMachine(config)`: preserves strict generic inference for TypeScript machine definitions. - `MachineService`: callable service type for creating machines. - `Machine`: runtime object exposed to controllers, scopes, and templates. -- `MachineConfig`: configuration shape for initial mode, data, transitions, - and hooks. +- `MachineConfig`: public state-tree configuration shape for initial mode, + reactive data, states, transitions, hooks, and transition policy. +- `MachineSendResult`: structured transition result returned by `send()`. - `MachineSnapshot`: durable state shape returned by `snapshot()`. -Public methods and values exposed to callers include `current`, `data`, +Advanced direct-import surface: + +- Transition context, guard, update, hook, and policy types support reusable + package-level annotations. Inline definitions rely on contextual typing. +- `MachineDataOf`, `MachineEventsOf`, `MachineEventNamesOf`, and + `MachineModesOf` derive types for adapters built around reusable definitions. + +Normal applications use `defineMachine(config)`, `app.machine(name, config)`, +or inject `$machine`. Custom runtimes opt in through +`orchestrationModule` from the `runtime/orchestration` package entry. + +Public methods and values exposed to callers include readonly `current`, `data`, `send()`, `can()`, `matches()`, `snapshot()`, and `restore()`. +Pre-state-tree runtime compatibility has been removed from `$machine`. +Workflow-owned `transitions` are adapted inside `$workflow` before reaching the +machine runtime. + +## Type Ergonomics + +Machine definitions can be declared from a plain object without generic +parameters. Event names are inferred from the `on` maps, state names are +inferred from `states`, and payloads remain `unknown` until an explicit event +map supplies their types. + +Strict event names and payloads are still available by passing an explicit event +map to `defineMachine(config)` or +`MachineConfig`. + +State names are part of the type contract. When a state-tree config is passed +through `defineMachine(config)` or `$machine(config)`, the machine result carries +the mode union into `current`, `matches(mode)`, `send()` results, and +`snapshot().current`. Invalid static `to` targets are rejected when they do not +match the state tree. + +`MachineDataOf`, `MachineEventsOf`, `MachineEventNamesOf`, and `MachineModesOf` +derive surrounding types from an existing machine or definition so callers do +not have to repeat generic arguments. + ## Core Model Each machine is backed by one raw `machineTarget`. The target owns the durable @@ -44,31 +82,82 @@ The main flow for `send()` is: 2. Pick an active, non-destroyed scope binding when one exists. 3. Evaluate the optional transition guard inside that scope's `$batch()` scheduler. -4. Run the transition target when the guard passes. -5. Apply the returned mode string, or keep the previous mode for `false` and - `undefined`. +4. Run state-tree `before`/`update` when the guard passes. +5. Apply the explicit state-tree `to` mode, any `context.to` update, or the + current mode when neither is set. 6. Run exit, enter, and transition hooks when appropriate. 7. Schedule all live scope bindings for `current`, `data`, and discovered data keys. Important invariants: -- Missing transitions return `false` and do not run hooks. -- Guarded transitions return `false` without running the target or hooks when the - guard returns `false`. -- A handled transition returns `true`, including same-mode transitions. +- Missing transitions return `{ ok: false, status: "missing-transition" }` and + do not run hooks. +- Guarded transitions return `{ ok: false, status: "guard-denied" }` without + running the target or hooks when the guard returns `false`. +- A handled transition returns `{ ok: true }`, including same-mode transitions. +- `current` is read-only. Mode changes must go through `send()` or `restore()`. - The machine must remain usable after every observing scope is destroyed. - Restore mutates the existing data object where possible so proxies keep identity. - Snapshot and restore handle special keys such as `__proto__` as data, not as prototype mutation. +## State Tree Execution Contract + +The upgraded declaration path uses `states` instead of `transitions`. A state +entry owns an `on` table, and each event entry may define `to`, `guard`, +`before`, `update`, `after`, and `denied`. + +State-tree transition ordering is: + +1. `guard` +2. when the guard denies, transition-local `denied` +3. when the guard allows, transition-local `before` +4. transition-local `update` +5. global exit hook for the previous mode when the mode changes +6. mode assignment from explicit `to`, or the current mode when `to` is omitted +7. global enter hook for the next mode when the mode changes +8. transition-local `after` +9. global transition hook + +`update()` return values are ignored. Mode routing comes only from `to`; omit +`to` for same-mode data updates. + +If `before` or `update` throws, the mode remains unchanged and live bindings are +still scheduled for any data mutations that already happened. If `after`, +`enter`, `exit`, or the global transition hook throws, the assigned mode and +data mutations remain visible and live bindings are scheduled. + +Guard contexts expose `data` and `machine.data` as readonly in TypeScript. +Guards must be deterministic and side-effect-free because `can()` may evaluate +them repeatedly. `can()` does not run `denied`. Put denial side effects in +`denied`, successful transition mutations in `update`, `before`, or `after`, +and external orchestration in workflow command logic. + +## Send Results + +`send(type, payload)` returns the structured runtime result for controllers, +workflows, tests, and application orchestration. Use `can(type, payload)` for +boolean template gating. + +Send statuses are: + +- `transitioned`: a handled transition changed mode. +- `updated`: a handled transition stayed in the same mode. +- `missing-transition`: the current mode has no matching event. +- `guard-denied`: the transition guard returned `false`. +- `invalid-event`: JavaScript callers passed a non-string event name. +- `policy-denied`: the framework policy gate denied the transition. + ## Lifecycle `$machine(config)` creates an unbound target. Assigning that target to a -controller or scope property lets the scope proxy bind later through -`SCOPE_PROXY_BIND`. `$machine(scope, config)` immediately returns a scope -proxy around the machine when the supplied scope has a handler. +controller, AppContext model, service, workflow, runtime adapter, or scope +property lets the scope proxy bind later through `SCOPE_PROXY_BIND`. +`$machine(scope, config)` immediately returns a scope proxy around the machine +when the supplied scope has a handler, but that form is advanced runtime +compatibility and is not part of the public TypeScript service contract. Bindings are kept in a map keyed by scope id. Destroyed bindings are removed when a transition, restore, or active-binding lookup sees them. The machine @@ -78,8 +167,12 @@ any one scope. ## Lifecycle Contract - Machine construction is synchronous and does not touch browser APIs. -- `$machine(config)` creates an unbound runtime object; `$machine(scope, - config)` creates a scope proxy immediately when a handler is available. +- `$machine(config)` creates an unbound runtime object. +- AppContext models, controllers, named machine services, workflows, and runtime + adapters should own that object and let scopes observe it by assignment. +- `$machine(scope, config)` creates a scope proxy immediately when a handler is + available for advanced runtime compatibility, but ordinary examples should not + use it. - Scope observation is opt-in through assignment to a scope or controller property. A machine can be observed by several scopes over its lifetime. - Scope destruction never destroys the machine. Destroyed bindings are removed @@ -92,12 +185,16 @@ any one scope. - `current` and `data` are reactive when a machine is observed through a scope proxy. +- `current` is reactive but not writable. Direct assignment is not a supported + transition mechanism. - Transition and restore scheduling updates `current`, `data`, and discovered nested data keys for every live observing scope. - Unbound machines remain plain synchronous objects until assigned to a scope or controller property. +- AppContext-owned models can hold machines without a DOM root. Destroying every + root scope removes DOM observers but does not destroy the app-owned machine. - Destroyed observing scopes stop receiving updates after opportunistic binding - cleanup. The machine's own state remains valid. + cleanup. The machine's own mode and data remain valid. - Transitions and hooks are not event streams. Callers that need durable evidence should use `$workflow`. @@ -106,13 +203,65 @@ any one scope. - `$machine` has no retry, persistence, concurrency, or recovery policy. - The only policy-like behavior is transition legality: the current mode, transition table, and optional guard decide whether `send()` can run. -- Guard policy is evaluated synchronously during `can()` and `send()`. +- Guards and transition policy are evaluated synchronously during `can()` and + `send()`. +- Machine transition policy uses the shared framework `Policy` contract shape. + It is an optional config-level gate, not a second machine-only policy system. +- The default policy behavior is allow/no-op when no `policy` is configured or + a JavaScript caller returns a malformed decision. +- Policy contexts include `operation: "machine.transition"`, `machineId`, + `type`, `from`, `to`, `payload`, readonly `data`, readonly `machine.data`, + `metadata`, and framework `target`/`meta` aliases. +- Policy denial returns `policy-denied` from `send()` and `false` from `can()`. +- Policy denial does not run `denied`, `before`, `update`, `after`, enter hooks, + exit hooks, or global transition hooks. +- Policy decisions must be synchronous. Async policy belongs in `$workflow`, + services, or supervisors that project their result back through a machine + event. - Transition targets decide mode changes by returning a non-empty mode string or staying in the current mode with `false`, `undefined`, an empty string, or a non-string value. - Persistence timing, side effects, migrations, and external recovery remain application-, `$workflow`-, or supervisor-owned. +## Policy Integration Slice (Machine Events) + +This section defines how machine transitions consume framework policy gates +without changing the existing synchronous transition contract. + +Execution order: + +- `send(type, payload)`/`can(type, payload)` first evaluate existing transition + availability and machine guard logic. +- Framework policy gate is evaluated at the transition boundary before target + execution. +- Decision semantics: + - `allow`: continue normal guard/target execution. + - `deny`: transition is blocked (`send()` returns a denied result, `can()` + returns `false`). + +Context shape for machine gates: + +- `operation: "machine.transition"` +- `machineId`: optional config id. +- `type`: event name. +- `from`: current mode. +- `to`: candidate mode from state-tree `to`/`context.to`, or the current mode + when the event is a same-mode update. +- `payload`: untyped machine payload. +- `data`: readonly transition data. +- `machine`: readonly machine view. +- `metadata`/`meta`: optional config metadata. + +Compatibility requirements: + +- Default policy is no-op/allow to preserve existing behavior. +- Existing `guard` behavior remains authoritative for transition semantics. +- No transition side-effects are introduced by policy; only allow/deny control. +- `can()` remains deterministic and boolean; `send()` remains deterministic and + returns structured transition evidence. +- Static state-tree `to` gives policy a deterministic pre-execution context. + ## Dependency Replacement Contract - `$machine` replaces small finite-state, game-flow, wizard-flow, and UI mode @@ -123,18 +272,18 @@ any one scope. observed transitions, deterministic hooks, and snapshot/restore. - `$machine` intentionally does not replace async command orchestration, diagnostics, retries, persistence policy, or distributed state engines. -- Applications can keep using plain functions and objects in transition targets; +- Applications can keep using plain functions and objects in state-tree updates; hooks remain the escape hatch for app-owned side effects. ## Composition Contract - `$machine` is a primitive. - `$workflow` builds on `$machine` for legal mode transitions and reactive data. -- Workflow supervisors may persist or recover machine state indirectly through - workflow snapshots. +- Workflow supervisors may persist or recover machine mode/data indirectly + through workflow snapshots. - `$machine` must not depend on `$workflow`, storage, workers, service workers, router, HTTP, or realtime services. -- Application-owned adapters compose through transition targets and hooks, but +- Application-owned adapters compose through state-tree updates and hooks, but external resource ownership stays outside `$machine`. ## Failure Contract @@ -165,7 +314,10 @@ any one scope. ## Scheduling And Ordering - `send()` runs synchronously. -- `can()` may run a transition guard, but never runs the transition target. +- `can()` may run a transition guard, but never runs state-tree `update` or + `denied`. +- State-tree guard data is readonly at the type level; update contexts remain + mutable. - Transition work is wrapped in `$batch()` when an active live scope binding is available. - Nested `machine.send()` calls from hooks are included in the outer batch. @@ -191,8 +343,9 @@ For same-mode transitions, only `hooks.transition` runs. transition contexts. - `MachineBinding`: stores the observing scope handler and the machine proxy associated with that scope. -- `MachineTransitionMap`: maps modes to event handlers. -- `MachineTransitionDescriptor`: optional guarded form for one event handler. +- `MachineStateMap`: maps modes to state-tree nodes. +- `MachineStateTransitionMap`: maps state-tree events to transition + descriptors. - `MachineHooks`: stores exit hooks, enter hooks, and a global transition hook. ## Integration Points @@ -218,7 +371,12 @@ For same-mode transitions, only `hooks.transition` runs. ## Edge Cases -- Non-string event names return `false`. +- Non-string event names return `invalid-event` from `send()`. +- Missing transitions return `missing-transition` from `send()`. +- Denied guards return `guard-denied` from `send()`. +- Denied guards may run a transition-local `denied` hook, but never run + `before`, `update`, `after`, exit hooks, enter hooks, or global transition + hooks. - Guard functions should be side-effect-free because `can()` may run during template evaluation. - Invalid config objects throw during creation. @@ -244,19 +402,18 @@ transitions, and hooks so it can later bind to another scope. machine injectables. `MachineConfig` -: Public definition for initial mode, reactive data, transition table, and -optional hooks. +: Public definition for initial mode, reactive data, state-tree transitions, +optional hooks, and optional transition policy. -`MachineTransition` -: Function invoked by `send()`. It receives data, payload, and the active -machine proxy. +`MachineSendResult` +: Structured `send()` result with transition status and policy denial details. `MachineHooks` : Optional side-effect hooks for mode entry, mode exit, and any handled transition. `MachineSnapshot` -: Durable state object containing only `current` and cloned `data`. +: Durable snapshot object containing only `current` and cloned `data`. `MachineBinding` : Internal record for one observing scope handler and its machine proxy. @@ -270,7 +427,7 @@ transition. event and payload typing. - `machine.test.ts` is the browser-facing harness for examples and template integration. -- Tic tac toe coverage acts as the real workflow-style fixture for game-state +- Tic tac toe coverage acts as the real workflow-style fixture for game-flow behavior and persistence examples. ## Testing Notes diff --git a/src/services/machine/machine-types.spec.ts b/src/services/machine/machine-types.spec.ts index 0544d80e0..96be5d687 100644 --- a/src/services/machine/machine-types.spec.ts +++ b/src/services/machine/machine-types.spec.ts @@ -3,12 +3,18 @@ import { defineMachine } from "./machine.ts"; import type { Machine, MachineConfig, + MachineDataOf, + MachineEventTransitionConfig, + MachineEventTransitionContext, + MachineEventNamesOf, MachineEventMap, MachineHooks, + MachineSendResult, + MachineSendStatus, MachineService, + MachineModesOf, MachineSnapshot, - MachineTransitionContext, - MachineTransitionResult, + MachineTransitionPolicy, } from "./machine.ts"; interface SessionData { @@ -26,31 +32,44 @@ interface SessionEvents { reset: undefined; } +type SessionModes = "setup" | "waiting" | "playing"; + describe("$machine types", () => { it("typechecks strict machine definitions by default", () => { const machineService = null as unknown as MachineService; - const strictConfig = defineMachine({ + const strictConfig = defineMachine< + SessionData, + SessionEvents, + SessionModes + >({ initial: "setup", data: { roomId: "", error: "", } satisfies SessionData, - transitions: { + states: { setup: { - join(data, payload) { - data.roomId = payload.roomId; - - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, }, }, waiting: { - reset(data) { - data.roomId = ""; - data.error = ""; - - return "setup"; + on: { + reset: { + to: "setup", + update({ data }) { + data.roomId = ""; + data.error = ""; + }, + }, }, }, + playing: {}, }, hooks: { transition(context) { @@ -62,88 +81,155 @@ describe("$machine types", () => { }, }, }); - const configWithUnknownTransition = defineMachine< + const configWithUnknownTransition: MachineConfig< SessionData, - SessionEvents - >({ + SessionEvents, + SessionModes + > = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - // @ts-expect-error strict machine definitions reject unknown transition keys. - missing() { - return "setup"; + on: { + // @ts-expect-error strict machine definitions reject unknown event keys. + missing: { + to: "setup", + }, }, }, + waiting: {}, + playing: {}, }, - }); - const configWithWrongPayload = defineMachine({ + }; + const configWithWrongPayload: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - // @ts-expect-error join receives JoinPayload, not a string. - join(_data, _payload: string) { - return "waiting"; + on: { + join: { + to: "waiting", + // @ts-expect-error join receives JoinPayload, not a string. + update( + _context: MachineEventTransitionContext< + SessionData, + SessionEvents, + string, + SessionModes + >, + ) {}, + }, }, }, + waiting: {}, + playing: {}, }, - }); + }; const strictMachine = machineService(strictConfig); - const noEventMachine = machineService( - defineMachine({ + const dynamicMachine = machineService( + defineMachine, MachineEventMap, "idle">({ initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, }), ); strictMachine.send("join", { roomId: "abc" }); strictMachine.send("reset"); + dynamicMachine.send("start", { anything: true }); + dynamicMachine.send("anything"); // @ts-expect-error join requires a roomId payload. strictMachine.send("join", { id: "abc" }); // @ts-expect-error strict machines reject unknown event names. strictMachine.send("missing"); - // @ts-expect-error machines without typed events expose no sends by default. - noEventMachine.send("start"); expect(strictConfig.initial).toBe("setup"); expect(configWithUnknownTransition.initial).toBe("setup"); expect(configWithWrongPayload.initial).toBe("setup"); }); + it("infers machine state and event names without generics", () => { + const machineService = null as unknown as MachineService; + const config = defineMachine({ + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + update({ data }) { + data.roomId = "abc"; + }, + }, + }, + }, + waiting: {}, + }, + }); + const machine = machineService(config); + type ConfigData = MachineDataOf; + type ConfigEventName = MachineEventNamesOf; + + const data: ConfigData = { + roomId: "abc", + error: "", + }; + const eventName: ConfigEventName = "join"; + machine.send("join", { roomId: "abc" }); + // @ts-expect-error inferred machines reject undeclared event names. + machine.send("missing", 1); + + expect(data.roomId).toBe("abc"); + expect(eventName).toBe("join"); + }); + it("typechecks dynamic machine event maps with unknown payloads", () => { const machineService = null as unknown as MachineService; - const config = defineMachine({ + const config = defineMachine({ initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join(data, payload) { - // @ts-expect-error dynamic event payload is unknown until narrowed. - data.roomId = payload.roomId; - - if ( - typeof payload === "object" && - payload !== null && - "roomId" in payload && - typeof payload.roomId === "string" - ) { - data.roomId = payload.roomId; - } - - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + // @ts-expect-error dynamic event payload is unknown until narrowed. + data.roomId = payload.roomId; + + if ( + typeof payload === "object" && + payload !== null && + "roomId" in payload && + typeof payload.roomId === "string" + ) { + data.roomId = payload.roomId; + } + }, + }, }, }, + waiting: {}, + playing: {}, }, hooks: { transition(context) { @@ -161,11 +247,11 @@ describe("$machine types", () => { }); it("typechecks the public TypeScript machine contract", () => { - const hooks: MachineHooks = { + const hooks: MachineHooks = { enter: { - waiting(context: MachineTransitionContext) { + waiting(context) { context.data.error = ""; - context.machine.matches(context.to); + context.machine.matches(context.to ?? context.from); }, }, transition(context) { @@ -175,159 +261,541 @@ describe("$machine types", () => { context.machine.restore(snapshot); }, }; - const config: MachineConfig = { + const config: MachineConfig = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join(data, payload: JoinPayload): MachineTransitionResult { - data.roomId = payload.roomId; - - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, }, }, waiting: { - fail(data, reason: string): MachineTransitionResult { - data.error = reason; - - return false; - }, - reset(data) { - data.roomId = ""; - data.error = ""; - - return "setup"; + on: { + fail: { + update({ data, payload }) { + data.error = payload; + }, + }, + reset: { + to: "setup", + update({ data }) { + data.roomId = ""; + data.error = ""; + }, + }, }, }, + playing: {}, }, hooks, }; const machineService = null as unknown as MachineService; - const scope = null as unknown as ng.Scope; const machine: Machine = machineService(config); - const scopedMachine: Machine = machineService( - scope, - config, - ); + const namespaceConfig: ng.MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = config; const namespaceMachine: ng.Machine = machine; - const namespaceConfig: ng.MachineConfig = - config; const namespaceSnapshot: ng.MachineSnapshot = namespaceMachine.snapshot(); + expect(config.initial).toBe("setup"); + expect(namespaceConfig.initial).toBe("setup"); namespaceMachine.restore(namespaceSnapshot); - scopedMachine.send("join", { roomId: "abc" }); - scopedMachine.send("reset"); - scopedMachine.send("fail", "room_unavailable"); + const sendResult: MachineSendResult = + machine.send("join", { roomId: "abc" }); + const sendStatus: MachineSendStatus = sendResult.status; + + if (sendResult.ok) { + const successfulStatus: "transitioned" | "updated" = sendResult.status; + + void successfulStatus; + } else { + const failedStatus: Exclude< + MachineSendStatus, + "transitioned" | "updated" + > = sendResult.status; + + void failedStatus; + } + + void sendResult; + void sendStatus; + // @ts-expect-error machine current mode is readonly. + machine.current = "setup"; + machine.send("reset"); + machine.send("fail", "room_unavailable"); // @ts-expect-error join requires a roomId payload. - scopedMachine.send("join", { id: "abc" }); + machine.send("join", { id: "abc" }); // @ts-expect-error typed machines reject unknown event names. - scopedMachine.send("missing"); - expect(namespaceConfig.initial).toBe("setup"); + machine.send("missing"); + // @ts-expect-error explicit scope binding is not public API. + machineService(null as unknown as ng.Scope, config); + expect(config.initial).toBe("setup"); + expect(sendStatus).toBeDefined(); }); - it("typechecks typed transition maps", () => { - const validConfig: MachineConfig = { + it("typechecks guarded state transitions", () => { + const config: MachineConfig = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join(data, payload) { - data.roomId = payload.roomId; - - return "waiting"; + on: { + join: { + to: "waiting", + guard({ data, payload, machine }) { + const roomId: string = payload.roomId; + + return ( + data.error === "" && machine.matches("setup") && !!roomId + ); + }, + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, + fail: { + // @ts-expect-error fail receives a string payload, not JoinPayload. + guard( + _context: MachineEventTransitionContext< + SessionData, + SessionEvents, + JoinPayload, + SessionModes + >, + ) { + return true; + }, + update({ data, payload }) { + data.error = payload; + }, + }, }, }, + waiting: {}, + playing: {}, }, }; - const invalidConfig: MachineConfig = { + const machineService = null as unknown as MachineService; + const machine = machineService(config); + + // @ts-expect-error join requires its payload for payload-aware guards. + machine.can("join"); + machine.can("join", { roomId: "abc" }); + // @ts-expect-error provided join payloads must include a roomId. + machine.can("join", { id: "abc" }); + + expect(config.initial).toBe("setup"); + }); + + it("typechecks declarative state tree machine definitions", () => { + const machineService = null as unknown as MachineService; + const inferredConfig = defineMachine({ + initial: "idle", + data: { + count: 0, + }, + states: { + idle: { + on: { + start: { + to: "running", + }, + }, + }, + running: { + on: { + tick: { + update({ data }) { + data.count += 1; + }, + }, + stop: { + to: "idle", + }, + }, + }, + }, + }); + const config = defineMachine({ + id: "session", initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - // @ts-expect-error typed transition maps reject unknown event names. - missing() { - return "setup"; + on: { + join: { + to: "waiting", + guard({ data, payload, machine }) { + const roomId: string = payload.roomId; + + // @ts-expect-error guard data is readonly. + data.roomId = payload.roomId; + // @ts-expect-error machine data is readonly inside guards. + machine.data.error = ""; + + return ( + data.error === "" && machine.matches("setup") && !!roomId + ); + }, + update({ data, payload }) { + data.roomId = payload.roomId; + data.error = ""; + }, + before(context) { + const from: SessionModes = context.from; + + void from; + }, + after(context) { + const to: SessionModes | undefined = context.to; + + void to; + }, + denied(context) { + const machine: Machine = + context.machine; + + context.data.error = "denied"; + machine.matches(context.from); + }, + }, + fail: { + update({ data, payload }) { + data.error = payload; + }, + }, }, }, + waiting: { + on: { + reset: { + to: "setup", + update({ data }) { + data.roomId = ""; + data.error = ""; + }, + }, + }, + }, + playing: {}, + }, + }); + const sameModeUpdate: MachineEventTransitionConfig< + SessionData, + string, + SessionEvents, + SessionModes, + "setup" + > = { + update({ data, payload }) { + data.error = payload; }, }; - - expect(validConfig.initial).toBe("setup"); - expect(invalidConfig.initial).toBe("setup"); + const context: MachineEventTransitionContext< + SessionData, + SessionEvents, + JoinPayload, + SessionModes + > = { + type: "join", + from: "setup", + to: "waiting", + payload: { roomId: "abc" }, + data: { + roomId: "", + error: "", + }, + machine: null as unknown as Machine< + SessionData, + SessionEvents, + SessionModes + >, + }; + const inferredRunning: "idle" | "running" = + inferredConfig.states.idle.on?.start?.to ?? "running"; + const inferredMode: MachineModesOf = "idle"; + const inferredMachine = machineService(inferredConfig); + const inferredCurrent: "idle" | "running" = inferredMachine.current; + const inferredSnapshotCurrent: "idle" | "running" = + inferredMachine.snapshot().current; + + inferredMachine.matches("idle"); + // @ts-expect-error inferred machines reject unknown state names. + inferredMachine.matches("missing"); + + expect(inferredRunning).toBe("running"); + expect(inferredMode).toBe("idle"); + expect(inferredCurrent).toBe("idle"); + expect(inferredSnapshotCurrent).toBe("idle"); + expect(config.initial).toBe("setup"); + expect(sameModeUpdate.to).toBeUndefined(); + expect(context.payload.roomId).toBe("abc"); }); - it("typechecks guarded transition descriptors", () => { - const config = defineMachine({ + it("rejects invalid declarative state tree definitions", () => { + const invalidEventConfig: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join: { - guard(data, payload, machine) { - const roomId: string = payload.roomId; - - return data.error === "" && machine.matches("setup") && !!roomId; + on: { + // @ts-expect-error state tree definitions reject unknown event names. + missing: { + to: "waiting", }, - target(data, payload) { - data.roomId = payload.roomId; - - return "waiting"; + }, + }, + waiting: {}, + playing: {}, + }, + }; + const invalidModeConfig: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + // @ts-expect-error transition targets must be known modes. + to: "missing", }, }, - fail: { - // @ts-expect-error fail receives a string payload, not JoinPayload. - guard(_data, payload: JoinPayload) { - return !!payload.roomId; + }, + waiting: {}, + playing: {}, + }, + }; + const invalidPayloadConfig: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + // @ts-expect-error join receives JoinPayload, not a string. + update( + _context: MachineEventTransitionContext< + SessionData, + SessionEvents, + string, + SessionModes + >, + ) {}, }, - target(data, reason) { - data.error = reason; - - return false; + }, + }, + waiting: {}, + playing: {}, + }, + }; + const missingTargetAndUpdate: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + // @ts-expect-error transitions without to must define update. + reset: {}, + }, + }, + waiting: {}, + playing: {}, + }, + }; + const asyncGuardConfig: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + // @ts-expect-error guards must remain synchronous. + async guard() { + return true; + }, }, }, }, + waiting: {}, + playing: {}, + }, + }; + const invalidInferredModeConfig = defineMachine({ + initial: "playing", + data: { + turn: "x" as "x" | "o", + }, + states: { + playing: { + on: { + move: { + // @ts-expect-error inferred transition targets must be state keys. + to: "finished", + }, + }, + }, + complete: {}, }, }); - const machineService = null as unknown as MachineService; - const machine = machineService(config); - machine.can("join"); - machine.can("join", { roomId: "abc" }); - // @ts-expect-error provided join payloads must include a roomId. - machine.can("join", { id: "abc" }); + expect(invalidEventConfig.initial).toBe("setup"); + expect(invalidModeConfig.initial).toBe("setup"); + expect(invalidPayloadConfig.initial).toBe("setup"); + expect(missingTargetAndUpdate.initial).toBe("setup"); + expect(asyncGuardConfig.initial).toBe("setup"); + expect(invalidInferredModeConfig.initial).toBe("playing"); + }); - expect(config.initial).toBe("setup"); + it("typechecks machine transition policies", () => { + const policy: MachineTransitionPolicy< + SessionData, + SessionEvents, + SessionModes + > = (context) => { + const data: Readonly = context.data; + const machineData: Readonly = context.machine.data; + const type: string = context.type; + const operation: "machine.transition" = context.operation; + + // @ts-expect-error policy data is readonly. + context.data.roomId = "mutated"; + // @ts-expect-error policy machine data is readonly. + context.machine.data.error = "mutated"; + + expect(data.roomId).toBe(""); + expect(machineData.error).toBe(""); + expect(type).toBe("join"); + expect(operation).toBe("machine.transition"); + + return "allow"; + }; + const config: MachineConfig = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + }, + }, + }, + waiting: {}, + playing: {}, + }, + policy, + metadata: { + feature: "session", + }, + }; + const asyncPolicyConfig: MachineConfig< + SessionData, + SessionEvents, + SessionModes + > = { + initial: "setup", + data: { + roomId: "", + error: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + }, + }, + }, + waiting: {}, + playing: {}, + }, + // @ts-expect-error policies must remain synchronous. + policy: async () => "allow" as const, + }; + + expect(config.policy).toBe(policy); + expect(asyncPolicyConfig.initial).toBe("setup"); }); it("typechecks module.machine registration", () => { const module = null as unknown as ng.NgModule; - const config: ng.MachineConfig = { + const config: MachineConfig = { initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join(data, payload: JoinPayload) { - data.roomId = payload.roomId; - - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, }, }, + waiting: {}, + playing: {}, }, }; @@ -336,26 +804,179 @@ describe("$machine types", () => { expect(returnedModule).toBe(module); }); + it("preserves inferred definitions through module.machine registration", () => { + const module = null as unknown as ng.NgModule; + const config = defineMachine({ + initial: "idle", + data: { + count: 0, + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data }) { + data.count += 1; + }, + }, + }, + }, + running: { + on: { + tick: { + update({ data }) { + data.count += 1; + }, + }, + }, + }, + }, + policy: (context) => { + const from: "idle" | "running" = context.from; + + void from; + + return "allow"; + }, + hooks: { + enter: { + running({ data }) { + data.count += 1; + }, + }, + transition(context) { + const type: "start" | "tick" = context.type; + + void type; + }, + }, + }); + const machine = (null as unknown as MachineService)(config); + const returnedModule = module.machine("counterMachine", config); + const inlineModule = module.machine("inlineCounterMachine", { + initial: "idle", + data: { count: 0 }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data }) { + data.count += 1; + }, + }, + }, + }, + running: {}, + }, + }); + const factoryModule = module.machine("factoryCounterMachine", () => ({ + initial: "idle", + data: { count: 0 }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data }) { + data.count += 1; + }, + }, + }, + }, + running: {}, + }, + })); + + machine.send("start"); + machine.send("tick"); + // @ts-expect-error named definitions retain inferred event names. + machine.send("stop"); + // @ts-expect-error named definitions retain inferred state names. + machine.matches("stopped"); + + expect(returnedModule).toBe(module); + expect(inlineModule).toBe(module); + expect(factoryModule).toBe(module); + }); + + it("typechecks a small turn-based game without explicit generics", () => { + const gameConfig = defineMachine({ + initial: "crossTurn", + data: { + moves: 0, + winner: "" as "" | "cross" | "circle", + }, + states: { + crossTurn: { + on: { + move: { + to: "circleTurn", + update({ data }) { + data.moves += 1; + }, + }, + }, + }, + circleTurn: { + on: { + move: { + to: "crossTurn", + update({ data }) { + data.moves += 1; + }, + }, + win: { + to: "complete", + update({ data }) { + data.winner = "circle"; + }, + }, + }, + }, + complete: {}, + }, + }); + const game = (null as unknown as MachineService)(gameConfig); + + game.send("move"); + game.send("win"); + game.matches("complete"); + // @ts-expect-error game events are inferred from the state tree. + game.send("reset"); + // @ts-expect-error game states are inferred from the state tree. + game.matches("abandoned"); + + expect(gameConfig.initial).toBe("crossTurn"); + }); + it("typechecks module.machine registration from resolvable config", () => { const module = null as unknown as ng.NgModule; - const buildMachineConfig = (): ng.MachineConfig< + const buildMachineConfig = (): MachineConfig< SessionData, - SessionEvents + SessionEvents, + SessionModes > => ({ initial: "setup", data: { roomId: "", error: "", }, - transitions: { + states: { setup: { - join(data, payload: JoinPayload) { - data.roomId = payload.roomId; - - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, }, }, + waiting: {}, + playing: {}, }, }); diff --git a/src/services/machine/machine.spec.ts b/src/services/machine/machine.spec.ts index 803a38abf..39d05bee8 100644 --- a/src/services/machine/machine.spec.ts +++ b/src/services/machine/machine.spec.ts @@ -8,7 +8,7 @@ import type { MachineService } from "./machine.ts"; describe("$machine", () => { let $compile: ng.CompileService; let $machine: MachineService; - let $rootScope: ng.RootScopeService; + let $rootScope: ng.Scope; beforeEach(() => { window.angular = new Angular(); @@ -17,3902 +17,1296 @@ describe("$machine", () => { $compile = injector.get("$compile") as ng.CompileService; $machine = injector.get("$machine") as MachineService; - $rootScope = injector.get("$rootScope") as ng.RootScopeService; + $rootScope = injector.get("$rootScope") as ng.Scope; }); - it("updates templates after restore replaces Map and Set data", async () => { - const element = $compile( - '
{{ session.data.metadata.get("phase") }}' + - '{{ session.data.selected.has("a1") }}' + - '{{ session.data.selected.size }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "idle", - data: { - metadata: new Map([["phase", "idle"]]), - selected: new Set(), - }, - transitions: {}, - }); - - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("idle"); - expect(element.querySelector(".selected")?.textContent).toBe("false"); - expect(element.querySelector(".size")?.textContent).toBe("0"); - - $rootScope.session.restore({ - current: "ready", - data: { - metadata: new Map([["phase", "ready"]]), - selected: new Set(["a1"]), - }, - }); - - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("ready"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - expect(element.querySelector(".size")?.textContent).toBe("1"); - }); - - it("documents transitions that return an unknown mode", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - skip() { - return "unconfigured"; - }, - }, - configured: { - reset() { - return "setup"; - }, - }, - }, - }); - - expect(machine.can("skip")).toBe(true); - expect(machine.send("skip")).toBe(true); - expect(machine.current).toBe("unconfigured"); - expect(machine.matches("unconfigured")).toBe(true); - expect(machine.can("skip")).toBe(false); - expect(machine.can("reset")).toBe(false); - expect(machine.send("reset")).toBe(false); - }); - - it("locks down hook ordering around current mode reads", () => { - const calls: string[] = []; - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - calls.push(`transition:${machine.current}`); - - return "waiting"; - }, - }, - }, - hooks: { - exit: { - setup(context) { - calls.push( - `exit:${context.from}:${context.to}:${context.machine.current}`, - ); - }, - }, - enter: { - waiting(context) { - calls.push( - `enter:${context.from}:${context.to}:${context.machine.current}`, - ); - }, - }, - transition(context) { - calls.push( - `hook:${context.from}:${context.to}:${context.machine.current}`, - ); - }, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(calls).toEqual([ - "transition:setup", - "exit:setup:waiting:setup", - "enter:setup:waiting:waiting", - "hook:setup:waiting:waiting", - ]); - }); - - it("documents data ordering when hooks send nested transitions", () => { - const machine = $machine({ - initial: "setup", - data: { - log: [] as string[], - }, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - waiting: { - ready(data) { - data.log.push("nested-transition"); - - return "ready"; - }, - }, - }, - hooks: { - enter: { - waiting(context) { - context.data.log.push("outer-enter-before"); - expect(context.machine.send("ready")).toBe(true); - context.data.log.push("outer-enter-after"); - }, - ready(context) { - context.data.log.push("nested-enter"); - }, - }, - transition(context) { - context.data.log.push(`transition:${context.from}->${context.to}`); - }, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("ready"); - expect(machine.data.log).toEqual([ - "outer-enter-before", - "nested-transition", - "nested-enter", - "transition:waiting->ready", - "outer-enter-after", - "transition:setup->waiting", - ]); - }); - - it("restores after one of several bound scopes is destroyed", async () => { - const directiveScopes: ng.Scope[] = []; - - window.angular = new Angular(); - window.angular - .module("machineRoadmapRestoreApp", ["ng"]) - .directive("machinePanel", () => ({ - scope: true, - template: - '{{ session.current }}' + - '{{ session.data.status }}', - link(scope: ng.Scope) { - directiveScopes.push(scope); - scope.session.matches("setup"); - }, - })); - - const injector = createInjector(["machineRoadmapRestoreApp"]); - const compile = injector.get("$compile") as ng.CompileService; - const machine = (injector.get("$machine") as MachineService)({ - initial: "setup", - data: { - status: "idle", - }, - transitions: {}, - }); - const rootScope = injector.get("$rootScope") as ng.RootScopeService; - - rootScope.session = machine; - - const element = compile( - '
' + - '
', - )(rootScope); - - await wait(); - - expect(directiveScopes.length).toBe(2); - expect(element.querySelector(".first .status")?.textContent).toBe("idle"); - expect(element.querySelector(".second .status")?.textContent).toBe("idle"); - - directiveScopes[0].$destroy(); - - machine.restore({ - current: "ready", - data: { - status: "restored", - }, - }); - - await wait(); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("restored"); - expect(element.querySelector(".second .mode")?.textContent).toBe("ready"); - expect(element.querySelector(".second .status")?.textContent).toBe( - "restored", - ); - }); - - it("isolates named machine Map and Set data across injectors", () => { - window.angular = new Angular(); - window.angular - .module("namedMachineCollectionApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - metadata: new Map([["phase", "idle"]]), - selected: new Set(["a1"]), - }, - transitions: { - setup: { - select(data) { - data.metadata.set("phase", "selected"); - data.selected.add("b2"); - - return "ready"; - }, - }, - }, - }); - - const firstInjector = createInjector(["namedMachineCollectionApp"]); - const secondInjector = createInjector(["namedMachineCollectionApp"]); - const firstMachine = firstInjector.get("sessionMachine") as ng.Machine<{ - metadata: Map; - selected: Set; - }>; - const secondMachine = secondInjector.get("sessionMachine") as ng.Machine<{ - metadata: Map; - selected: Set; - }>; - - expect(firstMachine.data.metadata).not.toBe(secondMachine.data.metadata); - expect(firstMachine.data.selected).not.toBe(secondMachine.data.selected); - - expect(firstMachine.send("select")).toBe(true); - expect(firstMachine.current).toBe("ready"); - expect(firstMachine.data.metadata.get("phase")).toBe("selected"); - expect(firstMachine.data.selected.has("b2")).toBe(true); - - expect(secondMachine.current).toBe("setup"); - expect(secondMachine.data.metadata.get("phase")).toBe("idle"); - expect(secondMachine.data.selected.has("a1")).toBe(true); - expect(secondMachine.data.selected.has("b2")).toBe(false); - }); - - it("round-trips snapshots with cyclic data containing Map and Set values", () => { - const metadata = new Map([["phase", "idle"]]); - const selected = new Set(["a1"]); - - metadata.set("self", metadata); - selected.add(selected); - - const machine = $machine({ - initial: "setup", - data: { - metadata, - selected, - }, - transitions: {}, - }); - - const snapshot = machine.snapshot(); - - expect(snapshot.data.metadata).not.toBe(machine.data.metadata); - expect(snapshot.data.metadata.get("phase")).toBe("idle"); - expect(snapshot.data.metadata.get("self")).toBe(snapshot.data.metadata); - expect(snapshot.data.selected).not.toBe(machine.data.selected); - expect(snapshot.data.selected.has("a1")).toBe(true); - expect(snapshot.data.selected.has(snapshot.data.selected)).toBe(true); - - machine.data.metadata.set("phase", "mutated"); - machine.data.selected.add("b2"); - - machine.restore(snapshot); - - expect(machine.data.metadata).not.toBe(snapshot.data.metadata); - expect(machine.data.metadata.get("phase")).toBe("idle"); - expect(machine.data.metadata.get("self")).toBe(machine.data.metadata); - expect(machine.data.selected).not.toBe(snapshot.data.selected); - expect(machine.data.selected.has("a1")).toBe(true); - expect(machine.data.selected.has("b2")).toBe(false); - expect(machine.data.selected.has(machine.data.selected)).toBe(true); - }); - - it("keeps partial Map and Set mutations when transitions throw", async () => { - const error = new Error("transition failed"); - const element = $compile( - '
{{ session.data.metadata.get("phase") }}' + - '{{ session.data.selected.has("a1") }}' + - '{{ session.current }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - metadata: new Map([["phase", "idle"]]), - selected: new Set(), - }, - transitions: { - setup: { - fail(data) { - data.metadata.set("phase", "failed"); - data.selected.add("a1"); - - throw error; - }, - recover(data) { - data.metadata.set("phase", "recovered"); - - return "setup"; - }, - }, - }, - }); - - await wait(); - - expect(() => $rootScope.session.send("fail")).toThrow(error); - expect($rootScope.session.current).toBe("setup"); - expect($rootScope.session.data.metadata.get("phase")).toBe("failed"); - expect($rootScope.session.data.selected.has("a1")).toBe(true); - - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("failed"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - expect(element.querySelector(".mode")?.textContent).toBe("setup"); - - expect($rootScope.session.send("recover")).toBe(true); - - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("recovered"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - }); - - it("keeps partial Map and Set mutations when hooks throw", async () => { - const error = new Error("hook failed"); - let shouldThrow = true; - const element = $compile( - '
{{ session.data.metadata.get("phase") }}' + - '{{ session.data.selected.has("a1") }}' + - '{{ session.current }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - metadata: new Map([["phase", "idle"]]), - selected: new Set(), - }, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - waiting: { - reset(data) { - data.metadata.set("phase", "reset"); - - return "setup"; - }, - }, - }, - hooks: { - enter: { - waiting(context) { - context.data.metadata.set("phase", "entered"); - context.data.selected.add("a1"); - - if (shouldThrow) { - throw error; - } - }, - }, - }, - }); - - await wait(); - - expect(() => $rootScope.session.send("join")).toThrow(error); - expect($rootScope.session.current).toBe("waiting"); - expect($rootScope.session.data.metadata.get("phase")).toBe("entered"); - expect($rootScope.session.data.selected.has("a1")).toBe(true); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".phase")?.textContent).toBe("entered"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - - shouldThrow = false; - - expect($rootScope.session.send("reset")).toBe(true); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("setup"); - expect(element.querySelector(".phase")?.textContent).toBe("reset"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - }); - - it("updates templates when an enter hook throws after a mode change", async () => { - const error = new Error("enter failed"); - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "joining"; - - return "waiting"; - }, - }, - }, - hooks: { - enter: { - waiting(context) { - context.data.status = "entered"; - - throw error; - }, - }, - }, - }); - - await wait(); - - expect(() => $rootScope.session.send("join")).toThrow(error); - expect($rootScope.session.current).toBe("waiting"); - expect($rootScope.session.data.status).toBe("entered"); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".status")?.textContent).toBe("entered"); - }); - - it("updates templates when a transition hook throws after a mode change", async () => { - const error = new Error("transition hook failed"); - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "joining"; - - return "waiting"; - }, - }, - }, - hooks: { - transition(context) { - context.data.status = "transitioned"; - - throw error; - }, - }, - }); - - await wait(); - - expect(() => $rootScope.session.send("join")).toThrow(error); - expect($rootScope.session.current).toBe("waiting"); - expect($rootScope.session.data.status).toBe("transitioned"); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".status")?.textContent).toBe("transitioned"); - }); - - it("runs a tic tac toe state machine to a completed game", async () => { - type Player = "X" | "O"; - type Cell = Player | "-"; - - const winningLines = [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - [0, 3, 6], - [1, 4, 7], - [2, 5, 8], - [0, 4, 8], - [2, 4, 6], - ]; - const findWinner = (board: Cell[]): Player | "" => { - for (let i = 0, l = winningLines.length; i < l; i++) { - const [a, b, c] = winningLines[i]; - const mark = board[a]; - - if (mark !== "-" && mark === board[b] && mark === board[c]) { - return mark; - } - } - - return ""; - }; - const element = $compile( - '
{{ game.current }}' + - '{{ game.data.winner }}' + - '{{ game.data.nextPlayer }}' + - '{{ game.data.board.join("") }}
', - )($rootScope); - - $rootScope.game = $machine({ - initial: "playing", - data: { - board: ["-", "-", "-", "-", "-", "-", "-", "-", "-"] as Cell[], - nextPlayer: "X" as Player, - winner: "" as Player | "", - moveCount: 0, - lastError: "", - }, - transitions: { - playing: { - move(data, payload: { index: number }) { - const index = payload.index; - - if ( - !Number.isInteger(index) || - index < 0 || - index >= data.board.length || - data.board[index] !== "-" - ) { - data.lastError = "invalid_move"; - - return false; - } - - const player = data.nextPlayer; - - data.board[index] = player; - data.moveCount += 1; - data.lastError = ""; - - const winner = findWinner(data.board); - - if (winner) { - data.winner = winner; - - return winner === "X" ? "xWon" : "oWon"; - } - - if (data.moveCount === data.board.length) { - return "draw"; - } - - data.nextPlayer = player === "X" ? "O" : "X"; - - return "playing"; - }, - }, - }, - }); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("playing"); - expect(element.querySelector(".board")?.textContent).toBe("---------"); - - for (const index of [0, 3, 1, 4, 2]) { - expect($rootScope.game.send("move", { index })).toBe(true); - } - - await wait(); - - expect($rootScope.game.current).toBe("xWon"); - expect($rootScope.game.data.winner).toBe("X"); - expect($rootScope.game.data.nextPlayer).toBe("X"); - expect($rootScope.game.data.moveCount).toBe(5); - expect($rootScope.game.data.board).toEqual([ - "X", - "X", - "X", - "O", - "O", - "-", - "-", - "-", - "-", - ]); - expect($rootScope.game.can("move")).toBe(false); - expect($rootScope.game.send("move", { index: 5 })).toBe(false); - expect(element.querySelector(".mode")?.textContent).toBe("xWon"); - expect(element.querySelector(".winner")?.textContent).toBe("X"); - expect(element.querySelector(".next")?.textContent).toBe("X"); - expect(element.querySelector(".board")?.textContent).toBe("XXXOO----"); - }); - - it("persists a tic tac toe state machine with transition hooks", () => { - type Player = "X" | "O"; - type Cell = Player | "-"; - - const storageKey = "angular-ts-machine-tic-tac-toe-test"; - const winningLines = [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - [0, 3, 6], - [1, 4, 7], - [2, 5, 8], - [0, 4, 8], - [2, 4, 6], - ]; - const findWinner = (board: Cell[]): Player | "" => { - for (let i = 0, l = winningLines.length; i < l; i++) { - const [a, b, c] = winningLines[i]; - const mark = board[a]; - - if (mark !== "-" && mark === board[b] && mark === board[c]) { - return mark; - } - } - - return ""; - }; - let persistedWrites = 0; - const createGame = () => - $machine({ - initial: "playing", - data: { - board: ["-", "-", "-", "-", "-", "-", "-", "-", "-"] as Cell[], - nextPlayer: "X" as Player, - winner: "" as Player | "", - moveCount: 0, - lastError: "", - }, - transitions: { - playing: { - move(data, payload: { index: number }) { - const index = payload.index; - - if ( - !Number.isInteger(index) || - index < 0 || - index >= data.board.length || - data.board[index] !== "-" - ) { - data.lastError = "invalid_move"; - - return false; - } - - const player = data.nextPlayer; - - data.board[index] = player; - data.moveCount += 1; - data.lastError = ""; - - const winner = findWinner(data.board); - - if (winner) { - data.winner = winner; - - return winner === "X" ? "xWon" : "oWon"; - } - - if (data.moveCount === data.board.length) { - return "draw"; - } - - data.nextPlayer = player === "X" ? "O" : "X"; - - return "playing"; - }, - }, - }, - hooks: { - transition({ machine }) { - persistedWrites += 1; - localStorage.setItem( - storageKey, - JSON.stringify(machine.snapshot()), - ); - }, - }, - }); - - localStorage.removeItem(storageKey); - - try { - const game = createGame(); - - for (const index of [0, 3, 1, 4, 2]) { - expect(game.send("move", { index })).toBe(true); - } - - const saved = JSON.parse(localStorage.getItem(storageKey) || "null"); - - expect(persistedWrites).toBe(5); - expect(saved).toEqual({ - current: "xWon", - data: { - board: ["X", "X", "X", "O", "O", "-", "-", "-", "-"], - nextPlayer: "X", - winner: "X", - moveCount: 5, - lastError: "", - }, - }); - - persistedWrites = 0; - - const restoredGame = createGame(); - - restoredGame.restore(saved); - - expect(persistedWrites).toBe(0); - expect(restoredGame.current).toBe("xWon"); - expect(restoredGame.data.winner).toBe("X"); - expect(restoredGame.data.board.join("")).toBe("XXXOO----"); - expect(restoredGame.can("move")).toBe(false); - expect(restoredGame.send("move", { index: 5 })).toBe(false); - expect(persistedWrites).toBe(0); - } finally { - localStorage.removeItem(storageKey); - } - }); - - it("creates a reactive machine with current mode and data", async () => { - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.roomId }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - roomId: "", - }, - transitions: { - setup: { - join(data, payload: { roomId: string }) { - data.roomId = payload.roomId; - return "waiting"; - }, - }, - }, - }); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("setup"); - expect(element.querySelector(".room")?.textContent).toBe(""); - - expect($rootScope.session.matches("setup")).toBe(true); - expect($rootScope.session.can("join")).toBe(true); - expect($rootScope.session.can("matched")).toBe(false); - expect($rootScope.session.send("join", { roomId: "abc" })).toBe(true); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".room")?.textContent).toBe("abc"); - }); - - it("returns false for missing transitions without throwing", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: {}, - }); - - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(machine.current).toBe("setup"); - }); - - it("treats non-function and malformed transition entries as missing transitions", () => { - const machine = $machine({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - join: "waiting", - start: { - guard() { - return true; - }, - }, - play: { - target: "playing", - }, - }, - }, - }); - - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(machine.can("start")).toBe(false); - expect(machine.send("start")).toBe(false); - expect(machine.can("play")).toBe(false); - expect(machine.send("play")).toBe(false); - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(0); - }); - - it("ignores inherited transition modes and entries", () => { - const inheritedJoin = jasmine.createSpy("join").and.returnValue("waiting"); - const inheritedTransitions = Object.create({ - setup: { - join: inheritedJoin, - }, - }); - - inheritedTransitions.waiting = Object.create({ - start: jasmine.createSpy("start").and.returnValue("playing"), - }); - - const machine = $machine({ - initial: "setup", - data: {}, - transitions: inheritedTransitions, - }); - - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(machine.current).toBe("setup"); - expect(inheritedJoin).not.toHaveBeenCalled(); - - machine.restore({ - current: "waiting", - data: {}, - }); - - expect(machine.can("start")).toBe(false); - expect(machine.send("start")).toBe(false); - expect(machine.current).toBe("waiting"); - }); - - it("supports null-prototype transition maps and entries", () => { - const transitions = Object.create(null); - const setup = Object.create(null); - - setup.join = (data: { status: string }) => { - data.status = "waiting"; - - return "waiting"; - }; - transitions.setup = setup; - - const machine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions, - }); - - expect(machine.can("join")).toBe(true); - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("waiting"); - }); - - it("supports null-prototype hook maps", () => { - const enter = Object.create(null); - const hooks = Object.create(null); - - enter.waiting = ( - context: ng.MachineTransitionContext<{ - status: string; - }>, - ) => { - context.data.status = "entered"; - }; - hooks.enter = enter; - - const machine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks, - }); - - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("entered"); - }); - - it("supports null-prototype machine data at creation time", () => { - const data = Object.create(null); - - data.count = 0; - - const machine = $machine({ - initial: "idle", - data, - transitions: { - idle: { - tick(activeData) { - activeData.count += 1; - - return "idle"; - }, - }, - }, - }); - - expect(Object.getPrototypeOf(machine.data)).toBeNull(); - expect(machine.send("tick")).toBe(true); - expect(machine.data.count).toBe(1); - - const snapshot = machine.snapshot(); - - expect(snapshot.data).not.toBe(machine.data); - expect(snapshot.data.count).toBe(1); - }); - - it("returns false for non-string transition names from JavaScript callers", () => { - const join = jasmine.createSpy("join").and.returnValue("waiting"); - const symbolTransition = jasmine - .createSpy("symbolTransition") - .and.returnValue("waiting"); - const symbolType = Symbol("join"); - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join, - [symbolType]: symbolTransition, - }, - }, - }); - - expect(machine.can(undefined)).toBe(false); - expect(machine.send(undefined)).toBe(false); - expect(machine.can(symbolType)).toBe(false); - expect(machine.send(symbolType)).toBe(false); - expect(machine.current).toBe("setup"); - expect(join).not.toHaveBeenCalled(); - expect(symbolTransition).not.toHaveBeenCalled(); - }); - - it("validates machine config shapes", () => { - expect(() => $machine(undefined)).toThrowError( - "$machine requires a config object.", - ); - expect(() => - $machine({ - initial: "", - data: {}, - transitions: {}, - }), - ).toThrowError("$machine requires a non-empty initial mode."); - expect(() => - $machine({ - initial: "setup", - data: null, - transitions: {}, - }), - ).toThrowError("$machine requires a data object."); - expect(() => - $machine({ - initial: "setup", - data: {}, - transitions: null, - }), - ).toThrowError("$machine requires a transitions object."); - }); - - it("runs without an owning scope before any template observes it", () => { - const machine = $machine({ - initial: "setup", - data: { - error: "", - }, - transitions: { - setup: { - fail(data, reason: string) { - data.error = reason; - return false; - }, - }, - }, - }); - - expect(machine.send("fail", "room_unavailable")).toBe(true); - expect(machine.current).toBe("setup"); - expect(machine.data.error).toBe("room_unavailable"); - }); - - it("creates deep snapshots of current mode and data", () => { - const machine = $machine({ - initial: "setup", - data: { - room: { - id: "", - players: ["Ada"], - }, - }, - transitions: { - setup: { - join(data, payload: { roomId: string }) { - data.room.id = payload.roomId; - data.room.players.push("Grace"); - return "waiting"; - }, - }, - }, - }); - - expect(machine.send("join", { roomId: "abc" })).toBe(true); - - const snapshot = machine.snapshot(); - - expect(snapshot).toEqual({ - current: "waiting", - data: { - room: { - id: "abc", - players: ["Ada", "Grace"], - }, - }, - }); - expect(snapshot.data).not.toBe(machine.data); - expect(snapshot.data.room).not.toBe(machine.data.room); - - snapshot.current = "setup"; - snapshot.data.room.id = "mutated"; - snapshot.data.room.players.push("Linus"); - - expect(machine.current).toBe("waiting"); - expect(machine.data.room.id).toBe("abc"); - expect(machine.data.room.players).toEqual(["Ada", "Grace"]); - }); - - it("snapshots Map and Set data as independent clones", () => { - const machine = $machine({ - initial: "setup", - data: { - metadata: new Map([["phase", "setup"]]), - selected: new Set(["a1"]), - }, - transitions: {}, - }); - - const snapshot = machine.snapshot(); - - expect(snapshot.data.metadata).not.toBe(machine.data.metadata); - expect(snapshot.data.metadata.get("phase")).toBe("setup"); - expect(snapshot.data.selected).not.toBe(machine.data.selected); - expect(snapshot.data.selected.has("a1")).toBe(true); - - snapshot.data.metadata.set("phase", "mutated"); - snapshot.data.selected.add("b2"); - - expect(machine.data.metadata.get("phase")).toBe("setup"); - expect(machine.data.selected.has("b2")).toBe(false); - }); - - it("snapshots structured-clone compatible Date and typed array data", () => { - const machine = $machine({ - initial: "ready", - data: { - startedAt: new Date("2024-01-02T03:04:05.000Z"), - bytes: new Uint8Array([1, 2, 3]), - }, - transitions: {}, - }); - - const snapshot = machine.snapshot(); - - expect(snapshot.data.startedAt).not.toBe(machine.data.startedAt); - expect(snapshot.data.startedAt.getTime()).toBe( - machine.data.startedAt.getTime(), - ); - expect(snapshot.data.bytes).not.toBe(machine.data.bytes); - expect(Array.from(snapshot.data.bytes)).toEqual([1, 2, 3]); - - snapshot.data.startedAt.setUTCFullYear(2025); - snapshot.data.bytes[0] = 9; - - expect(machine.data.startedAt.getUTCFullYear()).toBe(2024); - expect(Array.from(machine.data.bytes)).toEqual([1, 2, 3]); - }); - - it("surfaces native structuredClone errors for non-cloneable snapshot data", () => { - const machine = $machine({ - initial: "setup", - data: { - value: 1, - callback() { - return "not cloneable"; - }, - }, - transitions: {}, - }); - - expect(() => machine.snapshot()).toThrow(); - }); - - it("snapshots and restores self-referential data without recursion", () => { - const data: Record = { - status: "idle", - }; - - data.self = data; - - const machine = $machine({ - initial: "setup", - data, - transitions: { - setup: { - join(machineData) { - machineData.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - expect(machine.send("join")).toBe(true); - - const snapshot = machine.snapshot(); - - expect(snapshot.current).toBe("waiting"); - expect(snapshot.data.self).toBe(snapshot.data); - - machine.restore({ - current: "setup", - data: { - status: "restored", - self: data, - }, - }); - - expect(machine.current).toBe("setup"); - expect(machine.data.status).toBe("restored"); - expect(machine.data.self).toBe(machine.data); - - machine.restore(snapshot); - - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("waiting"); - expect(machine.data.self).toBe(machine.data); - }); - - it("can restore from the live data object without changing identity", () => { - const machine = $machine({ - initial: "setup", - data: { - room: { - status: "idle", - }, - }, - transitions: {}, - }); - const dataRef = machine.data; - const roomRef = machine.data.room; - - machine.data.room.status = "ready"; - machine.restore({ - current: "waiting", - data: machine.data, - }); - - expect(machine.current).toBe("waiting"); - expect(machine.data).toBe(dataRef); - expect(machine.data.room).toBe(roomRef); - expect(machine.data.room.status).toBe("ready"); - }); - - it("restores null-prototype plain objects in place", () => { - const room = Object.create(null); - - room.status = "idle"; - room.stale = true; - - const machine = $machine({ - initial: "setup", - data: { - room, - }, - transitions: {}, - }); - const roomRef = machine.data.room; - const restoredRoom = Object.create(null); - - restoredRoom.status = "ready"; - - machine.restore({ - current: "ready", - data: { - room: restoredRoom, - }, - }); - - expect(machine.current).toBe("ready"); - expect(machine.data.room).toBe(roomRef); - expect(machine.data.room.status).toBe("ready"); - expect("stale" in machine.data.room).toBe(false); - }); - - it("restores mode and data in place without running hooks", () => { - const enter = jasmine.createSpy("enter"); - const exit = jasmine.createSpy("exit"); - const transitionHook = jasmine.createSpy("transition"); - const machine = $machine({ - initial: "setup", - data: { - room: { - id: "", - status: "idle", - }, - stale: true, - }, - transitions: { - setup: { - join(data, payload: { roomId: string }) { - data.room.id = payload.roomId; - data.room.status = "waiting"; - return "waiting"; - }, - }, - }, - hooks: { - exit: { - setup: exit, - }, - enter: { - waiting: enter, - }, - transition: transitionHook, - }, - }); - const dataRef = machine.data; - const roomRef = machine.data.room; - - expect(machine.send("join", { roomId: "abc" })).toBe(true); - expect(enter).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledTimes(1); - expect(transitionHook).toHaveBeenCalledTimes(1); - - enter.calls.reset(); - exit.calls.reset(); - transitionHook.calls.reset(); - - machine.restore({ - current: "setup", - data: { - room: { - id: "restored", - status: "ready", - }, - }, - }); - - expect(machine.current).toBe("setup"); - expect(machine.data).toBe(dataRef); - expect(machine.data.room).toBe(roomRef); - expect(machine.data.room).toEqual({ - id: "restored", - status: "ready", - }); - expect("stale" in machine.data).toBe(false); - expect(enter).not.toHaveBeenCalled(); - expect(exit).not.toHaveBeenCalled(); - expect(transitionHook).not.toHaveBeenCalled(); - }); - - it("restores cloned snapshot values for new and non-plain data", () => { - const machine = $machine({ - initial: "setup", - data: { - players: ["Ada"], - }, - transitions: {}, - }); - const snapshot = { - current: "waiting", - data: { - players: ["Grace", "Linus"], - }, - }; - - machine.restore(snapshot); - - expect(machine.current).toBe("waiting"); - expect(machine.data.players).toEqual(["Grace", "Linus"]); - expect(machine.data.players).not.toBe(snapshot.data.players); - - snapshot.data.players.push("Margaret"); - - expect(machine.data.players).toEqual(["Grace", "Linus"]); - }); - - it("replaces restored non-plain object values instead of merging them", () => { - const originalDate = new Date("2024-01-01T00:00:00.000Z"); - const originalMap = new Map([["phase", "setup"]]); - const originalSet = new Set(["setup"]); - const machine = $machine({ - initial: "setup", - data: { - startedAt: originalDate, - metadata: originalMap, - selected: originalSet, - }, - transitions: {}, - }); - const restoredDate = new Date("2024-02-01T00:00:00.000Z"); - const restoredMap = new Map([["phase", "waiting"]]); - const restoredSet = new Set(["waiting"]); - const snapshot = { - current: "waiting", - data: { - startedAt: restoredDate, - metadata: restoredMap, - selected: restoredSet, - }, - }; - - machine.restore(snapshot); - - expect(machine.current).toBe("waiting"); - expect(machine.data.startedAt).not.toBe(originalDate); - expect(machine.data.startedAt).not.toBe(restoredDate); - expect(machine.data.startedAt.getTime()).toBe(restoredDate.getTime()); - expect(machine.data.metadata).not.toBe(originalMap); - expect(machine.data.metadata).not.toBe(restoredMap); - expect(machine.data.metadata.get("phase")).toBe("waiting"); - expect(machine.data.selected).not.toBe(originalSet); - expect(machine.data.selected).not.toBe(restoredSet); - expect(machine.data.selected.has("waiting")).toBe(true); - - restoredDate.setFullYear(2025); - restoredMap.set("phase", "mutated"); - restoredSet.add("mutated"); - - expect(machine.data.startedAt.getUTCFullYear()).toBe(2024); - expect(machine.data.metadata.get("phase")).toBe("waiting"); - expect(machine.data.selected.has("mutated")).toBe(false); - }); - - it("replaces restored arrays instead of preserving stale indexes", () => { - const machine = $machine({ - initial: "setup", - data: { - players: ["Ada", "Grace", "Katherine"], - }, - transitions: {}, - }); - - const restoredPlayers = ["Margaret"]; - - machine.restore({ - current: "setup", - data: { - players: restoredPlayers, - }, - }); - - expect(machine.data.players).toEqual(["Margaret"]); - expect(machine.data.players.length).toBe(1); - expect(machine.data.players).not.toBe(restoredPlayers); - - restoredPlayers.push("Barbara"); - - expect(machine.data.players).toEqual(["Margaret"]); - }); - - it("replaces restored nested objects with primitive values", () => { - const machine = $machine({ - initial: "setup", - data: { - room: { - status: "waiting", - stale: true, - } as { status: string; stale?: boolean } | null, - }, - transitions: {}, - }); - - machine.restore({ - current: "setup", - data: { - room: null, - }, - }); - - expect(machine.data.room).toBeNull(); - }); - - it("updates transition availability from the restored mode", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - waiting: { - start() { - return "playing"; - }, - }, - }, - }); - - machine.restore({ - current: "waiting", - data: {}, - }); - - expect(machine.matches("waiting")).toBe(true); - expect(machine.can("join")).toBe(false); - expect(machine.can("start")).toBe(true); - expect(machine.send("start")).toBe(true); - expect(machine.matches("playing")).toBe(true); - }); - - it("updates templates after restore", async () => { - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.room.status }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", - data: { - room: { - status: "idle", - }, - }, - transitions: {}, - }); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("setup"); - expect(element.querySelector(".status")?.textContent).toBe("idle"); - - $rootScope.session.restore({ - current: "waiting", - data: { - room: { - status: "restored", - }, - }, - }); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".status")?.textContent).toBe("restored"); - }); - - it("updates templates when restore removes stale data keys", async () => { - const element = $compile( - '
{{ session.data.status }}' + - '{{ session.data.error }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "failed", - data: { - status: "failed", - error: "room_unavailable", - }, - transitions: {}, - }); - - await wait(); - - expect(element.querySelector(".status")?.textContent).toBe("failed"); - expect(element.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); - - $rootScope.session.restore({ - current: "ready", - data: { - status: "ready", - }, - }); - - await wait(); - - expect(element.querySelector(".status")?.textContent).toBe("ready"); - expect(element.querySelector(".error")?.textContent).toBe(""); - }); - - it("updates templates when restore removes stale nested data keys", async () => { - const element = $compile( - '
{{ session.data.room.status }}' + - '{{ session.data.room.error }}
', - )($rootScope); - - $rootScope.session = $machine({ - initial: "failed", - data: { - room: { - status: "failed", - error: "room_unavailable", - }, - }, - transitions: {}, - }); - - await wait(); - - expect(element.querySelector(".status")?.textContent).toBe("failed"); - expect(element.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); - - $rootScope.session.restore({ - current: "ready", - data: { - room: { - status: "ready", - }, - }, - }); - - await wait(); - - expect(element.querySelector(".status")?.textContent).toBe("ready"); - expect(element.querySelector(".error")?.textContent).toBe(""); - }); - - it("runs restore inside the owning scope batch", () => { - const batchSpy = spyOn($rootScope.$handler, "$batch").and.callThrough(); - - $rootScope.session = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: {}, - }); - - expect($rootScope.session.matches("setup")).toBe(true); - - $rootScope.session.restore({ - current: "waiting", - data: { - status: "restored", - }, - }); - - expect(batchSpy).toHaveBeenCalledTimes(1); - expect($rootScope.session.current).toBe("waiting"); - expect($rootScope.session.data.status).toBe("restored"); - }); - - it("validates restore snapshots", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: {}, - }); - - expect(() => machine.restore(undefined)).toThrowError( - "$machine restore requires a snapshot object.", - ); - expect(() => machine.restore({ current: "", data: {} })).toThrowError( - "$machine restore requires a non-empty current mode.", - ); - expect(() => - machine.restore({ current: "setup", data: undefined }), - ).toThrowError("$machine restore requires a data object."); - }); - - it("does not preserve stale keys from inherited snapshot data", () => { - const inheritedData = Object.create({ - status: "inherited", - stale: true, - }); - - inheritedData.status = "ready"; - - const machine = $machine({ - initial: "setup", - data: { - status: "idle", - stale: true, - }, - transitions: {}, - }); - - machine.restore({ - current: "ready", - data: inheritedData, - }); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("ready"); - expect("stale" in machine.data).toBe(false); - }); - - it("restores own __proto__ data keys without changing object prototypes", () => { - const machine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: {}, - }); - const snapshot = JSON.parse( - '{"current":"ready","data":{"status":"ready","__proto__":{"polluted":true}}}', - ); - const originalPrototype = Reflect.getPrototypeOf(machine.data); - - machine.restore(snapshot); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("ready"); - expect( - Object.prototype.hasOwnProperty.call(machine.data, "__proto__"), - ).toBe(true); - expect(machine.data.__proto__).toEqual({ - polluted: true, - }); - expect(Reflect.getPrototypeOf(machine.data)).toBe(originalPrototype); - expect({}.polluted).toBeUndefined(); - }); - - it("updates existing own __proto__ data keys without changing object prototypes", () => { - const data = { - status: "idle", - }; - - Object.defineProperty(data, "__proto__", { - value: { - previous: true, - }, - enumerable: true, - configurable: true, - writable: true, - }); - - const machine = $machine({ - initial: "setup", - data, - transitions: {}, - }); - const snapshot = JSON.parse( - '{"current":"ready","data":{"status":"ready","__proto__":{"polluted":true}}}', - ); - const originalPrototype = Reflect.getPrototypeOf(machine.data); - - machine.restore(snapshot); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("ready"); - expect( - Object.prototype.hasOwnProperty.call(machine.data, "__proto__"), - ).toBe(true); - expect(machine.data.__proto__).toEqual({ - polluted: true, - }); - expect(Reflect.getPrototypeOf(machine.data)).toBe(originalPrototype); - expect({}.polluted).toBeUndefined(); - }); - - it("restores nested own __proto__ data keys without changing object prototypes", () => { - const machine = $machine({ - initial: "setup", - data: { - room: { - status: "idle", - }, - }, - transitions: {}, - }); - const snapshot = JSON.parse( - '{"current":"ready","data":{"room":{"status":"ready","__proto__":{"polluted":true}}}}', - ); - const dataPrototype = Reflect.getPrototypeOf(machine.data); - const roomPrototype = Reflect.getPrototypeOf(machine.data.room); - - machine.restore(snapshot); - - expect(machine.current).toBe("ready"); - expect(machine.data.room.status).toBe("ready"); - expect( - Object.prototype.hasOwnProperty.call(machine.data.room, "__proto__"), - ).toBe(true); - expect(machine.data.room.__proto__).toEqual({ - polluted: true, - }); - expect(Reflect.getPrototypeOf(machine.data)).toBe(dataPrototype); - expect(Reflect.getPrototypeOf(machine.data.room)).toBe(roomPrototype); - expect({}.polluted).toBeUndefined(); - }); - - it("restores constructor prototype data without polluting object prototypes", () => { - const machine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: {}, - }); - const snapshot = { - current: "ready", - data: { - status: "ready", - constructor: { - prototype: { - polluted: true, - }, - }, - }, - }; - const originalPrototype = Reflect.getPrototypeOf(machine.data); - - machine.restore(snapshot); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("ready"); - expect( - Object.prototype.hasOwnProperty.call(machine.data, "constructor"), - ).toBe(true); - expect(machine.data.constructor).toEqual({ - prototype: { - polluted: true, - }, - }); - expect(Reflect.getPrototypeOf(machine.data)).toBe(originalPrototype); - expect({}.polluted).toBeUndefined(); - }); - - it("snapshots own __proto__ data keys without changing object prototypes", () => { - const data = { - status: "idle", - }; - - Object.defineProperty(data, "__proto__", { - value: { - label: "data", - }, - enumerable: true, - configurable: true, - writable: true, - }); - - const machine = $machine({ - initial: "setup", - data, - transitions: {}, - }); - const originalPrototype = Reflect.getPrototypeOf(machine.data); - - const snapshot = machine.snapshot(); - - expect(snapshot.current).toBe("setup"); - expect( - Object.prototype.hasOwnProperty.call(snapshot.data, "__proto__"), - ).toBe(true); - expect(snapshot.data.__proto__).toEqual({ - label: "data", - }); - expect(Reflect.getPrototypeOf(machine.data)).toBe(originalPrototype); - }); - - it("restores named machines registered through module.machine", () => { - window.angular = new Angular(); - window.angular - .module("namedMachineSnapshotApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - const injector = createInjector(["namedMachineSnapshotApp"]); - const machine = injector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - - expect(machine.send("join")).toBe(true); - - const snapshot = machine.snapshot(); - - machine.restore({ - current: "setup", - data: { - status: "restored", - }, - }); - - expect(machine.current).toBe("setup"); - expect(machine.data.status).toBe("restored"); - - machine.restore(snapshot); - - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("waiting"); - }); - - it("returns one named machine instance per injector", () => { - window.angular = new Angular(); - window.angular - .module("namedMachineSingletonApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - const injector = createInjector(["namedMachineSingletonApp"]); - const firstMachine = injector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - const secondMachine = injector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - - expect(firstMachine).toBe(secondMachine); - expect(firstMachine.send("join")).toBe(true); - expect(secondMachine.current).toBe("waiting"); - expect(secondMachine.data.status).toBe("waiting"); - }); - - it("returns separate named machine instances for separate injectors", () => { - window.angular = new Angular(); - window.angular - .module("namedMachineInjectorApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - const firstInjector = createInjector(["namedMachineInjectorApp"]); - const secondInjector = createInjector(["namedMachineInjectorApp"]); - const firstMachine = firstInjector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - const secondMachine = secondInjector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - - expect(firstMachine).not.toBe(secondMachine); - expect(firstMachine.send("join")).toBe(true); - expect(firstMachine.current).toBe("waiting"); - expect(firstMachine.data.status).toBe("waiting"); - expect(secondMachine.current).toBe("setup"); - expect(secondMachine.data.status).toBe("idle"); - }); - - it("does not mutate the config data object for named machines", () => { - const configData = { - room: { - status: "idle", - players: ["Ada"], - }, - }; - - window.angular = new Angular(); - window.angular - .module("namedMachineConfigDataApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: configData, - transitions: { - setup: { - join(data) { - data.room.status = "waiting"; - data.room.players.push("Grace"); - return "waiting"; - }, - }, - }, - }); - - const injector = createInjector(["namedMachineConfigDataApp"]); - const machine = injector.get("sessionMachine") as ng.Machine<{ - room: { - status: string; - players: string[]; - }; - }>; - - expect(machine.send("join")).toBe(true); - expect(machine.data.room.status).toBe("waiting"); - expect(machine.data.room.players).toEqual(["Ada", "Grace"]); - expect(configData).toEqual({ - room: { - status: "idle", - players: ["Ada"], - }, - }); - }); - - it("deeply isolates named machine data across separate injectors", () => { - window.angular = new Angular(); - window.angular - .module("namedMachineNestedInjectorApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - room: { - status: "idle", - players: ["Ada"], - }, - }, - transitions: { - setup: { - join(data) { - data.room.status = "waiting"; - data.room.players.push("Grace"); - return "waiting"; - }, - }, - }, - }); - - const firstInjector = createInjector(["namedMachineNestedInjectorApp"]); - const secondInjector = createInjector(["namedMachineNestedInjectorApp"]); - const firstMachine = firstInjector.get("sessionMachine") as ng.Machine<{ - room: { - status: string; - players: string[]; - }; - }>; - const secondMachine = secondInjector.get("sessionMachine") as ng.Machine<{ - room: { - status: string; - players: string[]; - }; - }>; - - expect(firstMachine.send("join")).toBe(true); - expect(firstMachine.data.room.status).toBe("waiting"); - expect(firstMachine.data.room.players).toEqual(["Ada", "Grace"]); - expect(secondMachine.data.room.status).toBe("idle"); - expect(secondMachine.data.room.players).toEqual(["Ada"]); - }); - - it("can bind explicitly to a scope", async () => { - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )($rootScope); - - $rootScope.session = $machine($rootScope, { - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - expect($rootScope.session.send("join")).toBe(true); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".status")?.textContent).toBe("waiting"); - }); - - it("keeps raw and scoped machines in sync after scoped transitions", () => { - const rawMachine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join(data) { - data.status = "waiting"; - return "waiting"; - }, - }, - }, - }); - - $rootScope.session = rawMachine; - - const scopedMachine = $rootScope.session as ng.Machine<{ - status: string; - }>; - - expect(scopedMachine.send("join")).toBe(true); - - expect(rawMachine.current).toBe("waiting"); - expect(rawMachine.data.status).toBe("waiting"); - expect(rawMachine.snapshot()).toEqual({ - current: "waiting", - data: { - status: "waiting", - }, - }); - expect(scopedMachine.snapshot()).toEqual(rawMachine.snapshot()); - }); - - it("keeps raw and sibling scoped machines in sync after scoped current assignment", async () => { - const rawMachine = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - waiting: { - start(data) { - data.status = "playing"; - return "playing"; - }, - }, - }, - }); - const firstScope = $rootScope.$new(); - const secondScope = $rootScope.$new(); - const firstElement = $compile( - '
{{ session.current }}
', - )(firstScope); - const secondElement = $compile( - '
{{ session.current }}
', - )(secondScope); - - firstScope.session = rawMachine; - secondScope.session = rawMachine; - - await wait(); - - firstScope.session.current = "waiting"; - - await wait(); - - expect(rawMachine.current).toBe("waiting"); - expect(firstScope.session.matches("waiting")).toBe(true); - expect(secondScope.session.matches("waiting")).toBe(true); - expect(firstElement.querySelector(".mode")?.textContent).toBe("waiting"); - expect(secondElement.querySelector(".mode")?.textContent).toBe("waiting"); - expect(rawMachine.can("start")).toBe(true); - expect(secondScope.session.send("start")).toBe(true); - expect(rawMachine.current).toBe("playing"); - }); - - it("checks transition availability without running the transition", () => { - const transition = jasmine - .createSpy("transition") - .and.returnValue("waiting"); - - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join: transition, - }, - }, - }); - - expect(machine.can("join")).toBe(true); - expect(transition).not.toHaveBeenCalled(); - expect(machine.current).toBe("setup"); - }); - - it("runs descriptor transitions without guards", () => { - const machine = $machine({ - initial: "setup", - data: { - roomId: "", - }, - transitions: { - setup: { - join: { - target(data, payload: { roomId: string }) { - data.roomId = payload.roomId; - - return "waiting"; - }, - }, - }, - }, - }); - - expect(machine.can("join", { roomId: "abc" })).toBe(true); - expect(machine.send("join", { roomId: "abc" })).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.data.roomId).toBe("abc"); - }); - - it("checks guarded transition availability without running the target", () => { - const guard = jasmine.createSpy("guard").and.returnValue(true); - const target = jasmine.createSpy("target").and.returnValue("waiting"); - const machine = $machine({ - initial: "setup", - data: { - enabled: true, - }, - transitions: { - setup: { - join: { - guard, - target, - }, - }, - }, - }); - - expect(machine.can("join")).toBe(true); - expect(guard).toHaveBeenCalledWith(machine.data, undefined, machine); - expect(target).not.toHaveBeenCalled(); - expect(machine.current).toBe("setup"); - }); - - it("skips guarded transitions when the guard returns false", () => { - const target = jasmine.createSpy("target").and.returnValue("waiting"); - const machine = $machine({ - initial: "setup", - data: { - enabled: false, - }, - transitions: { - setup: { - join: { - guard(data) { - return data.enabled; - }, - target, - }, - }, - }, - }); - - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(target).not.toHaveBeenCalled(); - expect(machine.current).toBe("setup"); - }); - - it("passes payloads to transition guards", () => { - const machine = $machine({ - initial: "setup", - data: { - roomId: "", - }, - transitions: { - setup: { - join: { - guard(_data, payload: { roomId?: string }) { - return payload.roomId === "abc"; - }, - target(data, payload: { roomId: string }) { - data.roomId = payload.roomId; - - return "waiting"; - }, - }, - }, - }, - }); - - expect(machine.can("join", { roomId: "bad" })).toBe(false); - expect(machine.send("join", { roomId: "bad" })).toBe(false); - expect(machine.current).toBe("setup"); - expect(machine.data.roomId).toBe(""); - - expect(machine.can("join", { roomId: "abc" })).toBe(true); - expect(machine.send("join", { roomId: "abc" })).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.data.roomId).toBe("abc"); - }); - - it("runs the guarded transition documentation example", () => { - const session = $machine({ - initial: "setup", - data: { - roomId: "", - inviteCode: "ready", - }, - transitions: { - setup: { - join: { - guard(data, message: { roomId: string }) { - return !!data.inviteCode && message.roomId !== ""; - }, - target(data, message: { roomId: string }) { - data.roomId = message.roomId; - - return "waiting"; - }, - }, - }, - }, - }); - - expect(session.can("join", { roomId: "" })).toBe(false); - expect(session.send("join", { roomId: "" })).toBe(false); - expect(session.matches("setup")).toBe(true); - - expect(session.can("join", { roomId: "abc" })).toBe(true); - expect(session.send("join", { roomId: "abc" })).toBe(true); - expect(session.matches("waiting")).toBe(true); - expect(session.data.roomId).toBe("abc"); - }); - - it("updates can and matches from the current mode", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - waiting: { - matched() { - return "playing"; - }, - }, - }, - }); - - expect(machine.matches("setup")).toBe(true); - expect(machine.can("join")).toBe(true); - expect(machine.can("matched")).toBe(false); - - expect(machine.send("join")).toBe(true); - - expect(machine.matches("setup")).toBe(false); - expect(machine.matches("waiting")).toBe(true); - expect(machine.can("join")).toBe(false); - expect(machine.can("matched")).toBe(true); - }); - - it("fails closed when JavaScript callers assign an invalid current mode", () => { - const join = jasmine.createSpy("join").and.returnValue("waiting"); - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join, - }, - }, - }); - - (machine as unknown as { current: unknown }).current = undefined; - - expect(machine.matches("setup")).toBe(false); - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(join).not.toHaveBeenCalled(); - - (machine as unknown as { current: unknown }).current = ""; - - expect(machine.can("join")).toBe(false); - expect(machine.send("join")).toBe(false); - expect(join).not.toHaveBeenCalled(); - }); - - it("treats false and undefined transition results as staying in the current mode", () => { - const machine = $machine({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - stayFalse(data) { - data.count += 1; - return false; - }, - stayUndefined(data) { - data.count += 1; - }, - }, - }, - }); - - expect(machine.send("stayFalse")).toBe(true); - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(1); - - expect(machine.send("stayUndefined")).toBe(true); - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(2); - }); - - it("keeps the original mode when a staying transition mutates machine.current", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - stayFalse(_data, _payload, activeMachine) { - activeMachine.current = "mutated"; - - return false; - }, - stayUndefined(_data, _payload, activeMachine) { - activeMachine.current = "mutated"; - }, - }, - }, - }); - - expect(machine.send("stayFalse")).toBe(true); - expect(machine.current).toBe("setup"); - - expect(machine.send("stayUndefined")).toBe(true); - expect(machine.current).toBe("setup"); - }); - - it("uses the returned mode when a transition also mutates machine.current", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join(_data, _payload, activeMachine) { - activeMachine.current = "manual"; - - return "waiting"; - }, - }, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.matches("manual")).toBe(false); - }); - - it("treats non-string transition results as staying in the current mode", () => { - const machine = $machine({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - stayNull(data) { - data.count += 1; - - return null; - }, - stayObject(data) { - data.count += 1; - - return { - mode: "waiting", - }; - }, - }, - }, - }); - - expect(machine.send("stayNull")).toBe(true); - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(1); - - expect(machine.send("stayObject")).toBe(true); - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(2); - }); - - it("treats empty string transition results as staying in the current mode", () => { - const transitionHook = jasmine.createSpy("transition"); - const enter = jasmine.createSpy("enter"); - const exit = jasmine.createSpy("exit"); - const machine = $machine({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - stayEmpty(data) { - data.count += 1; - - return ""; - }, - }, - }, - hooks: { - exit: { - setup: exit, - }, - enter: { - setup: enter, - }, - transition: transitionHook, - }, - }); - - expect(machine.send("stayEmpty")).toBe(true); - - expect(machine.current).toBe("setup"); - expect(machine.data.count).toBe(1); - expect(exit).not.toHaveBeenCalled(); - expect(enter).not.toHaveBeenCalled(); - expect(transitionHook).toHaveBeenCalledOnceWith( - jasmine.objectContaining({ - type: "stayEmpty", - from: "setup", - to: "setup", - }), - ); - }); - - it("uses the returned mode when an exit hook mutates machine.current", () => { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks: { - exit: { - setup(context) { - context.machine.current = "manual"; - }, - }, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.matches("manual")).toBe(false); - }); - - it("allows same-mode transitions to run repeatedly for data updates", () => { - const machine = $machine({ - initial: "playing", - data: { - version: 0, - }, - transitions: { - playing: { - snapshot(data, payload: { version: number }) { - data.version = payload.version; - return "playing"; - }, - }, - }, - }); - - expect(machine.send("snapshot", { version: 1 })).toBe(true); - expect(machine.send("snapshot", { version: 2 })).toBe(true); - expect(machine.current).toBe("playing"); - expect(machine.data.version).toBe(2); - }); - - it("runs enter, exit, and transition hooks with transition context", () => { - const calls: string[] = []; - - const machine = $machine({ - initial: "setup", - data: { - roomId: "", - }, - transitions: { - setup: { - join(data, payload: { roomId: string }) { - data.roomId = payload.roomId; - return "waiting"; - }, - }, - }, - hooks: { - exit: { - setup(context) { - calls.push( - `exit:${context.from}->${context.to}:${context.data.roomId}`, - ); - expect(context.type).toBe("join"); - expect(context.payload).toEqual({ roomId: "abc" }); - expect(context.machine.current).toBe("setup"); - }, - }, - enter: { - waiting(context) { - calls.push( - `enter:${context.from}->${context.to}:${context.data.roomId}`, - ); - expect(context.machine.current).toBe("waiting"); - }, - }, - transition(context) { - calls.push( - `transition:${context.from}->${context.to}:${context.data.roomId}`, - ); - expect(context.machine.current).toBe("waiting"); - }, - }, - }); - - expect(machine.send("join", { roomId: "abc" })).toBe(true); - - expect(calls).toEqual([ - "exit:setup->waiting:abc", - "enter:setup->waiting:abc", - "transition:setup->waiting:abc", - ]); - }); - - it("rethrows transition errors without running hooks and restores batching", () => { - const error = new Error("transition failed"); - const enter = jasmine.createSpy("enter"); - const exit = jasmine.createSpy("exit"); - const transitionHook = jasmine.createSpy("transition"); - let shouldThrow = true; - const scope = $rootScope.$new(); - - scope.session = $machine({ - initial: "setup", - data: { - attempts: 0, - }, - transitions: { - setup: { - join(data) { - data.attempts += 1; - - if (shouldThrow) { - throw error; - } - - return "waiting"; - }, - }, - }, - hooks: { - enter: { - waiting: enter, - }, - exit: { - setup: exit, - }, - transition: transitionHook, - }, - }); - - expect(scope.session.matches("setup")).toBe(true); - expect(() => scope.session.send("join")).toThrow(error); - expect(scope.session.matches("setup")).toBe(true); - expect(scope.session.data.attempts).toBe(1); - expect(exit).not.toHaveBeenCalled(); - expect(enter).not.toHaveBeenCalled(); - expect(transitionHook).not.toHaveBeenCalled(); - - shouldThrow = false; - - expect(scope.session.send("join")).toBe(true); - expect(scope.session.matches("waiting")).toBe(true); - expect(scope.session.data.attempts).toBe(2); - expect(exit).toHaveBeenCalledTimes(1); - expect(enter).toHaveBeenCalledTimes(1); - expect(transitionHook).toHaveBeenCalledTimes(1); - }); - - it("does not run hooks for missing transitions", () => { - const enter = jasmine.createSpy("enter"); - const exit = jasmine.createSpy("exit"); - const transitionHook = jasmine.createSpy("transition"); - - const machine = $machine({ - initial: "setup", - data: {}, - transitions: {}, - hooks: { - enter: { - waiting: enter, - }, - exit: { - setup: exit, - }, - transition: transitionHook, - }, - }); - - expect(machine.send("join")).toBe(false); - expect(exit).not.toHaveBeenCalled(); - expect(enter).not.toHaveBeenCalled(); - expect(transitionHook).not.toHaveBeenCalled(); - }); - - it("ignores inherited mode and transition hooks", () => { - const exit = jasmine.createSpy("exit"); - const enter = jasmine.createSpy("enter"); - const transitionHook = jasmine.createSpy("transition"); - const objectPrototype = Object.prototype as Record; - - objectPrototype.setup = exit; - objectPrototype.waiting = enter; - objectPrototype.transition = transitionHook; - - try { - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks: { - exit: {}, - enter: {}, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(exit).not.toHaveBeenCalled(); - expect(enter).not.toHaveBeenCalled(); - expect(transitionHook).not.toHaveBeenCalled(); - } finally { - delete objectPrototype.setup; - delete objectPrototype.waiting; - delete objectPrototype.transition; - } - }); - - it("passes the raw machine in hook context before scope binding", () => { - let hookMachine: ng.Machine | undefined; - - const machine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks: { - transition(context) { - hookMachine = context.machine; - }, - }, - }); - - expect(machine.send("join")).toBe(true); - expect(hookMachine).toBe(machine); - }); - - it("passes the scoped machine proxy in hook context after scope binding", () => { - let hookMachine: ng.Machine | undefined; - const rawMachine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks: { - transition(context) { - hookMachine = context.machine; - }, - }, - }); - - $rootScope.session = rawMachine; - - const scopedMachine = $rootScope.session as ng.Machine; - - expect(scopedMachine.send("join")).toBe(true); - expect(hookMachine).toBe(scopedMachine); - expect(hookMachine).not.toBe(rawMachine); - }); - - it("passes the scoped machine proxy to transitions after scope binding", () => { - let transitionMachine: ng.Machine | undefined; - const rawMachine = $machine({ - initial: "setup", - data: {}, - transitions: { - setup: { - join(_data, _payload, activeMachine) { - transitionMachine = activeMachine; - - return "waiting"; - }, - }, - }, - }); - - $rootScope.session = rawMachine; + it("runs a tic tac toe state machine to a completed game", async () => { + type Player = "X" | "O"; + type Cell = Player | "-"; - const scopedMachine = $rootScope.session as ng.Machine; + const winningLines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + const findWinner = (board: Cell[]): Player | "" => { + for (let i = 0, l = winningLines.length; i < l; i++) { + const [a, b, c] = winningLines[i]; + const mark = board[a]; - expect(scopedMachine.send("join")).toBe(true); - expect(transitionMachine).toBe(scopedMachine); - expect(transitionMachine).not.toBe(rawMachine); - }); + if (mark !== "-" && mark === board[b] && mark === board[c]) { + return mark; + } + } - it("runs transition hooks for same-mode transitions without enter or exit", () => { - const calls: string[] = []; + return ""; + }; + const element = $compile( + '
{{ game.current }}' + + '{{ game.data.winner }}' + + '{{ game.data.nextPlayer }}' + + '{{ game.data.board.join("") }}
', + )($rootScope); - const machine = $machine({ + $rootScope.game = $machine({ initial: "playing", data: { - count: 0, + board: ["-", "-", "-", "-", "-", "-", "-", "-", "-"] as Cell[], + nextPlayer: "X" as Player, + winner: "" as Player | "", + moveCount: 0, + lastError: "", }, - transitions: { + states: { playing: { - tick(data) { - data.count += 1; - return "playing"; - }, - }, - }, - hooks: { - exit: { - playing() { - calls.push("exit"); - }, - }, - enter: { - playing() { - calls.push("enter"); + on: { + move: { + guard({ + data, + payload, + }: { + data: { board: Cell[] }; + payload: { index: number }; + }) { + const index = payload.index; + + return ( + Number.isInteger(index) && + index >= 0 && + index < data.board.length && + data.board[index] === "-" + ); + }, + update(context) { + const { data, payload } = context; + const player = data.nextPlayer; + + data.board[payload.index] = player; + data.moveCount += 1; + data.lastError = ""; + + const winner = findWinner(data.board); + + if (winner) { + data.winner = winner; + context.to = winner === "X" ? "xWon" : "oWon"; + return; + } + + if (data.moveCount === data.board.length) { + context.to = "draw"; + return; + } + + data.nextPlayer = player === "X" ? "O" : "X"; + }, + denied({ data }) { + data.lastError = "invalid_move"; + }, + }, }, }, - transition(context) { - calls.push( - `transition:${context.from}->${context.to}:${context.data.count}`, - ); - }, + xWon: {}, + oWon: {}, + draw: {}, }, }); - expect(machine.send("tick")).toBe(true); - - expect(calls).toEqual(["transition:playing->playing:1"]); - }); + await wait(); - it("runs transition hooks for false and undefined transition results without enter or exit", () => { - const calls: string[] = []; + expect(element.querySelector(".mode")?.textContent).toBe("playing"); + expect(element.querySelector(".board")?.textContent).toBe("---------"); - const machine = $machine({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - stayFalse(data) { - data.count += 1; - return false; - }, - stayUndefined(data) { - data.count += 1; - }, - }, - }, - hooks: { - exit: { - setup() { - calls.push("exit"); - }, - }, - enter: { - setup() { - calls.push("enter"); - }, - }, - transition(context) { - calls.push( - `${context.type}:${context.from}->${context.to}:${context.data.count}`, - ); - }, - }, - }); + for (const index of [0, 3, 1, 4, 2]) { + expect($rootScope.game.send("move", { index }).ok).toBe(true); + } - expect(machine.send("stayFalse")).toBe(true); - expect(machine.send("stayUndefined")).toBe(true); + await wait(); - expect(calls).toEqual([ - "stayFalse:setup->setup:1", - "stayUndefined:setup->setup:2", + expect($rootScope.game.current).toBe("xWon"); + expect($rootScope.game.data.winner).toBe("X"); + expect($rootScope.game.data.nextPlayer).toBe("X"); + expect($rootScope.game.data.moveCount).toBe(5); + expect($rootScope.game.data.board).toEqual([ + "X", + "X", + "X", + "O", + "O", + "-", + "-", + "-", + "-", ]); + expect($rootScope.game.can("move")).toBe(false); + expect($rootScope.game.send("move", { index: 5 }).ok).toBe(false); + expect(element.querySelector(".mode")?.textContent).toBe("xWon"); + expect(element.querySelector(".winner")?.textContent).toBe("X"); + expect(element.querySelector(".next")?.textContent).toBe("X"); + expect(element.querySelector(".board")?.textContent).toBe("XXXOO----"); }); - it("allows configs with only one hook kind or no hooks", () => { - const createConfig = (hooks?: ng.MachineHooks<{ count: number }>) => ({ - initial: "setup", - data: { - count: 0, - }, - transitions: { - setup: { - join(data: { count: number }) { - data.count += 1; - return "waiting"; - }, - }, - }, - hooks, - }); + it("restores a machine snapshot without reviving transition hooks", () => { + type Player = "X" | "O"; + type Cell = Player | "-"; - expect(() => $machine(createConfig()).send("join")).not.toThrow(); - expect(() => - $machine( - createConfig({ - enter: { - waiting(context) { - context.data.count += 1; - }, - }, - }), - ).send("join"), - ).not.toThrow(); - expect(() => - $machine( - createConfig({ - exit: { - setup(context) { - context.data.count += 1; - }, - }, - }), - ).send("join"), - ).not.toThrow(); - expect(() => - $machine( - createConfig({ - transition(context) { - context.data.count += 1; - }, - }), - ).send("join"), - ).not.toThrow(); - }); + const storageKey = "angular-ts-machine-tic-tac-toe-test"; + const winningLines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + const findWinner = (board: Cell[]): Player | "" => { + for (let i = 0, l = winningLines.length; i < l; i++) { + const [a, b, c] = winningLines[i]; + const mark = board[a]; - it("updates templates from hook mutations", async () => { - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )($rootScope); + if (mark !== "-" && mark === board[b] && mark === board[c]) { + return mark; + } + } - $rootScope.session = $machine({ - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join() { - return "waiting"; + return ""; + }; + let persistedWrites = 0; + const createGame = () => + $machine({ + initial: "playing", + data: { + board: ["-", "-", "-", "-", "-", "-", "-", "-", "-"] as Cell[], + nextPlayer: "X" as Player, + winner: "" as Player | "", + moveCount: 0, + lastError: "", + }, + states: { + playing: { + on: { + move: { + guard({ + data, + payload, + }: { + data: { board: Cell[] }; + payload: { index: number }; + }) { + const index = payload.index; + + return ( + Number.isInteger(index) && + index >= 0 && + index < data.board.length && + data.board[index] === "-" + ); + }, + update(context) { + const { data, payload } = context; + const player = data.nextPlayer; + + data.board[payload.index] = player; + data.moveCount += 1; + data.lastError = ""; + + const winner = findWinner(data.board); + + if (winner) { + data.winner = winner; + context.to = winner === "X" ? "xWon" : "oWon"; + return; + } + + if (data.moveCount === data.board.length) { + context.to = "draw"; + return; + } + + data.nextPlayer = player === "X" ? "O" : "X"; + }, + denied({ data }) { + data.lastError = "invalid_move"; + }, + }, + }, }, + xWon: {}, + oWon: {}, + draw: {}, }, - }, - hooks: { - enter: { - waiting(context) { - context.data.status = "entered"; + hooks: { + transition({ machine }) { + persistedWrites += 1; + localStorage.setItem( + storageKey, + JSON.stringify(machine.snapshot()), + ); }, }, - }, - }); + }); - expect($rootScope.session.send("join")).toBe(true); + localStorage.removeItem(storageKey); - await wait(); + try { + const game = createGame(); - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".status")?.textContent).toBe("entered"); - }); + for (const index of [0, 3, 1, 4, 2]) { + expect(game.send("move", { index }).ok).toBe(true); + } - it("updates templates from nested hook data mutations", async () => { - const element = $compile( - '
{{ session.data.room.status }}
', - )($rootScope); + const saved = JSON.parse(localStorage.getItem(storageKey) || "null"); - $rootScope.session = $machine({ - initial: "setup", - data: { - room: { - status: "idle", - }, - }, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - }, - hooks: { - enter: { - waiting(context) { - context.data.room.status = "waiting"; - }, + expect(persistedWrites).toBe(5); + expect(saved).toEqual({ + current: "xWon", + data: { + board: ["X", "X", "X", "O", "O", "-", "-", "-", "-"], + nextPlayer: "X", + winner: "X", + moveCount: 5, + lastError: "", }, - }, - }); + }); + + persistedWrites = 0; - expect($rootScope.session.send("join")).toBe(true); + const restoredGame = createGame(); - await wait(); + restoredGame.restore(saved); - expect(element.querySelector(".status")?.textContent).toBe("waiting"); + expect(persistedWrites).toBe(0); + expect(restoredGame.current).toBe("xWon"); + expect(restoredGame.data.winner).toBe("X"); + expect(restoredGame.data.board.join("")).toBe("XXXOO----"); + expect(restoredGame.can("move")).toBe(false); + expect(restoredGame.send("move", { index: 5 }).ok).toBe(false); + expect(persistedWrites).toBe(0); + } finally { + localStorage.removeItem(storageKey); + } }); - it("runs hook mutations inside the owning scope batch", async () => { - const batchSpy = spyOn($rootScope.$handler, "$batch").and.callThrough(); + it("runs a session wizard through static route transitions", async () => { const element = $compile( - '
{{ session.data.one }}' + - '{{ session.data.two }}
', + '
{{ wizard.current }}' + + '{{ wizard.data.acceptedTerms }}' + + '{{ wizard.data.error }}
', )($rootScope); - - $rootScope.session = $machine({ - initial: "setup", + + $rootScope.wizard = $machine({ + initial: "profile", data: { - one: "idle", - two: "idle", + name: "", + acceptedTerms: false, + error: "", }, - transitions: { - setup: { - join() { - return "waiting"; + states: { + profile: { + on: { + next: { + to: "terms", + guard({ data }) { + return data.name.trim() !== ""; + }, + denied({ data }) { + data.error = "name_required"; + }, + }, }, }, - }, - hooks: { - enter: { - waiting(context) { - context.data.one = "ready"; - context.data.two = "ready"; + terms: { + on: { + back: { + to: "profile", + }, + accept: { + update({ data }) { + data.acceptedTerms = true; + }, + }, + submit: { + to: "complete", + guard({ data }) { + return data.acceptedTerms; + }, + denied({ data }) { + data.error = "terms_required"; + }, + }, }, }, + complete: {}, }, }); - expect($rootScope.session.send("join")).toBe(true); - expect(batchSpy).toHaveBeenCalledTimes(1); + await wait(); + + expect($rootScope.wizard.send("next").ok).toBe(false); + expect($rootScope.wizard.current).toBe("profile"); + expect($rootScope.wizard.data.error).toBe("name_required"); + + $rootScope.wizard.data.name = "Ada"; + + expect($rootScope.wizard.send("next").ok).toBe(true); + expect($rootScope.wizard.current).toBe("terms"); + expect($rootScope.wizard.send("submit").ok).toBe(false); + expect($rootScope.wizard.data.error).toBe("terms_required"); + expect($rootScope.wizard.send("accept").ok).toBe(true); + expect($rootScope.wizard.current).toBe("terms"); + expect($rootScope.wizard.send("submit").ok).toBe(true); await wait(); - expect(element.querySelector(".one")?.textContent).toBe("ready"); - expect(element.querySelector(".two")?.textContent).toBe("ready"); + expect($rootScope.wizard.current).toBe("complete"); + expect(element.querySelector(".mode")?.textContent).toBe("complete"); + expect(element.querySelector(".accepted")?.textContent).toBe("true"); + expect(element.querySelector(".error")?.textContent).toBe("terms_required"); }); - it("allows hooks to send nested transitions inside the same batch", async () => { - const batchSpy = spyOn($rootScope.$handler, "$batch").and.callThrough(); - const calls: string[] = []; - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )($rootScope); + it("supports null-prototype state maps and event entries", () => { + const states = Object.create(null); + const setup = Object.create(null); + const on = Object.create(null); - $rootScope.session = $machine({ + on.join = { + to: "waiting", + update({ data }: { data: { status: string } }) { + data.status = "waiting"; + }, + }; + setup.on = on; + states.setup = setup; + states.waiting = Object.create(null); + + const machine = $machine({ initial: "setup", data: { status: "idle", }, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, - waiting: { - ready(data) { - data.status = "ready"; - return "ready"; - }, - }, - }, - hooks: { - enter: { - waiting(context) { - calls.push("enter:waiting"); - expect(context.machine.send("ready")).toBe(true); - }, - ready() { - calls.push("enter:ready"); - }, - }, - }, + states, }); - expect($rootScope.session.send("join")).toBe(true); - expect($rootScope.session.current).toBe("ready"); - expect(batchSpy).toHaveBeenCalledTimes(2); - - await wait(); - - expect(calls).toEqual(["enter:waiting", "enter:ready"]); - expect(element.querySelector(".mode")?.textContent).toBe("ready"); - expect(element.querySelector(".status")?.textContent).toBe("ready"); + expect(machine.can("join")).toBe(true); + expect(machine.send("join").ok).toBe(true); + expect(machine.current).toBe("waiting"); + expect(machine.data.status).toBe("waiting"); }); - it("rethrows hook errors, keeps prior mutations, and restores batching", () => { - const phases = ["exit", "enter", "transition"]; - - for (let i = 0, l = phases.length; i < l; i++) { - const phase = phases[i]; - const error = new Error(`${phase} failed`); - let shouldThrow = true; - const scope = $rootScope.$new(); - const machine = $machine({ + it("runs named state-tree machines registered through module.machine", () => { + window.angular = new Angular(); + window.angular + .module("namedStateMachineApp", ["ng"]) + .machine("sessionMachine", { initial: "setup", data: { - attempts: 0, status: "idle", }, - transitions: { + states: { setup: { - join(data) { - data.attempts += 1; - data.status = "joining"; - return "waiting"; + on: { + join: { + to: "waiting", + update({ data }) { + data.status = "waiting"; + }, + }, }, }, - waiting: { - reset(data) { - data.status = "idle"; - return "setup"; - }, - }, - }, - hooks: { - exit: { - setup() { - if (phase === "exit" && shouldThrow) { - throw error; - } - }, - }, - enter: { - waiting(context) { - context.data.status = "entered"; - - if (phase === "enter" && shouldThrow) { - throw error; - } - }, - }, - transition(context) { - context.data.status = "transitioned"; - - if (phase === "transition" && shouldThrow) { - throw error; - } - }, + waiting: {}, }, }); - scope.session = machine; - expect(scope.session.matches("setup")).toBe(true); - - expect(() => scope.session.send("join")).toThrow(error); - expect(scope.session.data.attempts).toBe(1); - - shouldThrow = false; - - if (scope.session.matches("waiting")) { - expect(scope.session.send("reset")).toBe(true); - } - - expect(scope.session.send("join")).toBe(true); - expect(scope.session.matches("waiting")).toBe(true); - expect(scope.session.data.attempts).toBe(2); - } - }); - - it("validates hook config shapes", () => { - const createConfig = (hooks: unknown) => ({ - initial: "setup", - data: {}, - transitions: {}, - hooks, - }); + const injector = createInjector(["namedStateMachineApp"]); + const machine = injector.get("sessionMachine") as ng.Machine<{ + status: string; + }>; - expect(() => $machine(createConfig("bad"))).toThrowError( - "$machine hooks must be an object.", - ); - expect(() => $machine(createConfig({ enter: "bad" }))).toThrowError( - "$machine hooks.enter must be an object.", - ); - expect(() => $machine(createConfig({ exit: "bad" }))).toThrowError( - "$machine hooks.exit must be an object.", - ); - expect(() => - $machine(createConfig({ enter: { setup: "bad" } })), - ).toThrowError("$machine hooks.enter entries must be functions."); - expect(() => - $machine(createConfig({ exit: { setup: "bad" } })), - ).toThrowError("$machine hooks.exit entries must be functions."); - expect(() => $machine(createConfig({ transition: "bad" }))).toThrowError( - "$machine hooks.transition must be a function.", - ); + expect(machine.send("join").ok).toBe(true); + expect(machine.current).toBe("waiting"); + expect(machine.data.status).toBe("waiting"); }); - it("binds lazily when a scope proxy reads the machine", () => { - const batchSpy = spyOn($rootScope.$handler, "$batch").and.callThrough(); + it("returns distinct send results for invalid events and denied guards", () => { const machine = $machine({ initial: "setup", - data: {}, - transitions: { - setup: { - join() { - return "waiting"; - }, - }, + data: { + allowed: false, }, - }); - - $rootScope.session = machine; - - expect(batchSpy).not.toHaveBeenCalled(); - expect($rootScope.session.matches("setup")).toBe(true); - expect(machine.send("join")).toBe(true); - expect(batchSpy).toHaveBeenCalled(); - }); - - it("runs transitions inside the owning scope batch", () => { - const batchSpy = spyOn($rootScope.$handler, "$batch").and.callThrough(); - - $rootScope.session = $machine({ - initial: "setup", - data: {}, - transitions: { + states: { setup: { - join() { - return "waiting"; + on: { + join: { + to: "waiting", + guard({ data }) { + return data.allowed; + }, + }, }, }, + waiting: {}, }, }); - const machine = $rootScope.session as ng.Machine; - - expect(machine.send("join")).toBe(true); - expect(batchSpy).toHaveBeenCalled(); + expect( + (machine as unknown as { send(type: unknown): unknown }).send(null), + ).toEqual( + jasmine.objectContaining({ + ok: false, + status: "invalid-event", + type: "", + from: "setup", + to: "setup", + }), + ); + expect(machine.send("join")).toEqual( + jasmine.objectContaining({ + ok: false, + status: "guard-denied", + type: "join", + from: "setup", + to: "setup", + }), + ); + expect(machine.current).toBe("setup"); }); - it("persists after one owning scope is destroyed and can bind to another scope", async () => { + it("returns structured send results for declarative state transitions", () => { const machine = $machine({ initial: "setup", data: { roomId: "", }, - transitions: { + states: { setup: { - join(data, payload: { roomId: string }) { - data.roomId = payload.roomId; - return "waiting"; + on: { + join: { + to: "waiting", + update({ data, payload }) { + data.roomId = payload.roomId; + }, + }, + touch: { + update({ data }) { + data.roomId = "same"; + }, + }, }, }, + waiting: {}, }, }); - const firstScope = $rootScope.$new(); + const touched = machine.send("touch"); - firstScope.session = machine; - expect(firstScope.session.matches("setup")).toBe(true); + expect(touched).toEqual( + jasmine.objectContaining({ + ok: true, + status: "updated", + type: "touch", + from: "setup", + to: "setup", + payload: undefined, + }), + ); + expect(touched.data).toBe(machine.data); + expect(touched.machine).toBe(machine); - firstScope.$destroy(); + const joined = machine.send("join", { roomId: "abc" }); - expect(machine.send("join", { roomId: "abc" })).toBe(true); + expect(joined).toEqual( + jasmine.objectContaining({ + ok: true, + status: "transitioned", + type: "join", + from: "setup", + to: "waiting", + payload: { roomId: "abc" }, + }), + ); + expect(joined.data.roomId).toBe("abc"); + expect(joined.data).toBe(machine.data); + expect(joined.machine).toBe(machine); expect(machine.current).toBe("waiting"); - expect(machine.data.roomId).toBe("abc"); - - const secondScope = $rootScope.$new(); - const element = $compile( - '
{{ session.current }}' + - '{{ session.data.roomId }}
', - )(secondScope); - - secondScope.session = machine; - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("waiting"); - expect(element.querySelector(".room")?.textContent).toBe("abc"); }); - it("restores through one scoped proxy and updates another scoped proxy", async () => { - const machine = $machine({ - initial: "setup", - data: { - status: "idle", + it("supports the explicit scope overload", () => { + const machine = $machine($rootScope, { + initial: "idle", + data: { count: 0 }, + states: { + idle: { + on: { + increment: { + update({ data }) { + data.count += 1; + }, + }, + }, + }, }, - transitions: {}, }); - const firstScope = $rootScope.$new(); - const secondScope = $rootScope.$new(); - const firstElement = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )(firstScope); - const secondElement = $compile( - '
{{ session.current }}' + - '{{ session.data.status }}
', - )(secondScope); - firstScope.session = machine; - secondScope.session = machine; - - await wait(); + expect(machine.send("increment").ok).toBeTrue(); + expect(machine.data.count).toBe(1); + expect(machine.$handler).toBe($rootScope.$handler); + }); - expect(firstElement.querySelector(".status")?.textContent).toBe("idle"); - expect(secondElement.querySelector(".status")?.textContent).toBe("idle"); + it("falls back to the current mode when hooks clear a transition target", () => { + const machine = $machine({ + initial: "idle", + data: { count: 0 }, + states: { + idle: { + on: { + increment: { + to: "done", + guard(context) { + context.to = undefined; + + return true; + }, + update(context) { + context.data.count += 1; + context.to = undefined; + }, + }, + }, + }, + done: {}, + }, + policy: (context) => { + expect(context.to).toBe("idle"); - firstScope.session.restore({ - current: "ready", - data: { - status: "restored", + return "allow"; }, }); - await wait(); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("restored"); - expect(firstElement.querySelector(".mode")?.textContent).toBe("ready"); - expect(firstElement.querySelector(".status")?.textContent).toBe("restored"); - expect(secondElement.querySelector(".mode")?.textContent).toBe("ready"); - expect(secondElement.querySelector(".status")?.textContent).toBe( - "restored", + expect(machine.can("increment")).toBeTrue(); + expect(machine.send("increment")).toEqual( + jasmine.objectContaining({ + ok: true, + status: "updated", + from: "idle", + to: "idle", + }), ); }); - it("updates multiple machines observed by the same scope independently", async () => { - const first = $machine({ - initial: "idle", + it("runs declarative state transitions with explicit routing", () => { + const calls: string[] = []; + const machine = $machine({ + initial: "setup", data: { - count: 0, + roomId: "", + status: "idle", }, - transitions: { - idle: { - start(data) { - data.count += 1; - - return "running"; + states: { + setup: { + on: { + join: { + to: "waiting", + guard({ data, payload, machine: activeMachine }) { + calls.push(`guard:${activeMachine.current}:${payload.roomId}`); + + return data.status === "idle" && payload.roomId !== ""; + }, + before({ from, to }) { + calls.push(`before:${from}->${to}`); + }, + update({ data, payload }) { + calls.push(`update:${payload.roomId}`); + data.roomId = payload.roomId; + data.status = "joining"; + + return "ignored"; + }, + after({ machine: activeMachine }) { + calls.push(`after:${activeMachine.current}`); + }, + }, }, }, + waiting: {}, }, - }); - const second = $machine({ - initial: "closed", - data: { - count: 0, - }, - transitions: { - closed: { - open(data) { - data.count += 1; - - return "open"; + hooks: { + exit: { + setup({ machine: activeMachine }) { + calls.push(`exit:${activeMachine.current}`); + }, + }, + enter: { + waiting({ machine: activeMachine }) { + calls.push(`enter:${activeMachine.current}`); }, }, + transition({ from, to }) { + calls.push(`transition:${from}->${to}`); + }, }, }); - const scope = $rootScope.$new(); - const element = $compile( - '
{{ first.current }}' + - '{{ first.data.count }}' + - '{{ second.current }}' + - '{{ second.data.count }}
', - )(scope); - - scope.first = first; - scope.second = second; - - await wait(); - - expect(element.querySelector(".first-mode")?.textContent).toBe("idle"); - expect(element.querySelector(".first-count")?.textContent).toBe("0"); - expect(element.querySelector(".second-mode")?.textContent).toBe("closed"); - expect(element.querySelector(".second-count")?.textContent).toBe("0"); - - expect(scope.first.send("start")).toBe(true); - - await wait(); - - expect(element.querySelector(".first-mode")?.textContent).toBe("running"); - expect(element.querySelector(".first-count")?.textContent).toBe("1"); - expect(element.querySelector(".second-mode")?.textContent).toBe("closed"); - expect(element.querySelector(".second-count")?.textContent).toBe("0"); - - expect(scope.second.send("open")).toBe(true); + expect(machine.can("join", { roomId: "abc" })).toBe(true); + expect(calls).toEqual(["guard:setup:abc"]); - await wait(); + calls.length = 0; - expect(element.querySelector(".first-mode")?.textContent).toBe("running"); - expect(element.querySelector(".first-count")?.textContent).toBe("1"); - expect(element.querySelector(".second-mode")?.textContent).toBe("open"); - expect(element.querySelector(".second-count")?.textContent).toBe("1"); + expect(machine.send("join", { roomId: "abc" }).ok).toBe(true); + expect(machine.current).toBe("waiting"); + expect(machine.data).toEqual({ + roomId: "abc", + status: "joining", + }); + expect(calls).toEqual([ + "guard:setup:abc", + "before:setup->waiting", + "update:abc", + "exit:setup", + "enter:waiting", + "after:waiting", + "transition:setup->waiting", + ]); }); - it("updates sibling scoped templates when a transition removes a data key", async () => { + it("allows declarative transitions with to and no update", () => { const machine = $machine({ - initial: "failed", - data: { - status: "failed", - error: "room_unavailable", - }, - transitions: { - failed: { - recover(data) { - data.status = "ready"; - delete data.error; - - return "ready"; + initial: "setup", + data: {}, + states: { + setup: { + on: { + join: { + to: "waiting", + }, }, }, + waiting: {}, }, }); - const firstScope = $rootScope.$new(); - const secondScope = $rootScope.$new(); - const firstElement = $compile( - '
{{ session.data.status }}' + - '{{ session.data.error }}
', - )(firstScope); - const secondElement = $compile( - '
{{ session.data.status }}' + - '{{ session.data.error }}
', - )(secondScope); - - firstScope.session = machine; - secondScope.session = machine; - - await wait(); - expect(firstElement.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); - expect(secondElement.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); - - expect(firstScope.session.send("recover")).toBe(true); - - await wait(); - - expect(machine.current).toBe("ready"); - expect(machine.data.status).toBe("ready"); - expect("error" in machine.data).toBe(false); - expect(firstElement.querySelector(".status")?.textContent).toBe("ready"); - expect(firstElement.querySelector(".error")?.textContent).toBe(""); - expect(secondElement.querySelector(".status")?.textContent).toBe("ready"); - expect(secondElement.querySelector(".error")?.textContent).toBe(""); + expect(machine.send("join").ok).toBe(true); + expect(machine.current).toBe("waiting"); }); - it("updates sibling scoped templates when a transition removes a nested data key", async () => { + it("allows declarative same-mode updates without to", () => { const machine = $machine({ - initial: "failed", + initial: "playing", data: { - room: { - status: "failed", - error: "room_unavailable", - }, + version: 0, }, - transitions: { - failed: { - recover(data) { - data.room.status = "ready"; - delete data.room.error; - - return "ready"; + states: { + playing: { + on: { + snapshot: { + update({ data, payload }) { + data.version = payload.version; + }, + }, }, }, }, }); - const firstScope = $rootScope.$new(); - const secondScope = $rootScope.$new(); - const firstElement = $compile( - '
{{ session.data.room.status }}' + - '{{ session.data.room.error }}
', - )(firstScope); - const secondElement = $compile( - '
{{ session.data.room.status }}' + - '{{ session.data.room.error }}
', - )(secondScope); - - firstScope.session = machine; - secondScope.session = machine; - - await wait(); - expect(firstElement.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); - expect(secondElement.querySelector(".error")?.textContent).toBe( - "room_unavailable", - ); + expect(machine.send("snapshot", { version: 1 }).ok).toBe(true); + expect(machine.send("snapshot", { version: 2 }).ok).toBe(true); + expect(machine.current).toBe("playing"); + expect(machine.data.version).toBe(2); + }); - expect(firstScope.session.send("recover")).toBe(true); + it("ignores declarative update return values when routing", () => { + const cases = [ + ["false", false], + ["undefined", undefined], + ["empty", ""], + ["object", { mode: "setup" }], + ["mode-like string", "setup"], + ["null", null], + ]; - await wait(); + for (const [label, value] of cases) { + const machine = $machine({ + initial: "setup", + data: { + label: "", + }, + states: { + setup: { + on: { + join: { + to: "waiting", + update({ data }) { + data.label = label; + + return value; + }, + }, + }, + }, + waiting: {}, + }, + }); - expect(machine.current).toBe("ready"); - expect(machine.data.room.status).toBe("ready"); - expect("error" in machine.data.room).toBe(false); - expect(firstElement.querySelector(".status")?.textContent).toBe("ready"); - expect(firstElement.querySelector(".error")?.textContent).toBe(""); - expect(secondElement.querySelector(".status")?.textContent).toBe("ready"); - expect(secondElement.querySelector(".error")?.textContent).toBe(""); + expect(machine.send("join").ok).withContext(label).toBe(true); + expect(machine.current).withContext(label).toBe("waiting"); + expect(machine.data.label).withContext(label).toBe(label); + } }); - it("updates templates when hooks add new data keys", async () => { - const element = $compile( - '
{{ session.data.ready }}' + - '{{ session.data.message }}
', - )($rootScope); - - $rootScope.session = $machine({ + it("runs declarative denied hooks when guard denies", () => { + const calls: string[] = []; + const machine = $machine({ initial: "setup", - data: {}, - transitions: { + data: { + allowed: false, + error: "", + }, + states: { setup: { - ready() { - return "ready"; + on: { + join: { + to: "waiting", + guard({ data }) { + calls.push("guard"); + + return data.allowed; + }, + before() { + calls.push("before"); + }, + update() { + calls.push("update"); + }, + after() { + calls.push("after"); + }, + denied({ data, machine: activeMachine }) { + calls.push(`denied:${activeMachine.current}`); + data.error = "not_allowed"; + }, + }, }, }, + waiting: {}, }, - hooks: { - enter: { - ready(context) { - context.data.ready = true; - context.data.message = "joined"; - }, + hooks: { + transition() { + calls.push("transition"); }, }, }); - await wait(); - - expect(element.querySelector(".ready")?.textContent).toBe(""); - expect(element.querySelector(".message")?.textContent).toBe(""); - - expect($rootScope.session.send("ready")).toBe(true); - - await wait(); - - expect(element.querySelector(".ready")?.textContent).toBe("true"); - expect(element.querySelector(".message")?.textContent).toBe("joined"); - }); + expect(machine.can("join")).toBe(false); - it("updates templates when transitions mutate Map and Set data", async () => { - const element = $compile( - '
{{ session.data.metadata.get("phase") }}' + - '{{ session.data.selected.has("a1") }}' + - '{{ session.data.selected.size }}
', - )($rootScope); + const result = machine.send("join"); - $rootScope.session = $machine({ - initial: "idle", + expect(result.ok).toBe(false); + expect(result.status).toBe("guard-denied"); + expect(machine.current).toBe("setup"); + expect(machine.data.error).toBe("not_allowed"); + expect(calls).toEqual(["guard", "guard", "denied:setup"]); + }); + + it("evaluates can and send with the same declarative guard inputs", () => { + const contexts: Array<{ + type: string; + from: string; + to?: string; + payload: unknown; + data: unknown; + machine: unknown; + }> = []; + const machine = $machine({ + initial: "setup", data: { - metadata: new Map([["phase", "idle"]]), - selected: new Set(), + allowed: true, }, - transitions: { - idle: { - select(data) { - data.metadata.set("phase", "selected"); - data.selected.add("a1"); - - return "idle"; + states: { + setup: { + on: { + join: { + to: "waiting", + guard(context) { + contexts.push({ + type: context.type, + from: context.from, + to: context.to, + payload: context.payload, + data: context.data, + machine: context.machine, + }); + + return context.data.allowed; + }, + }, }, }, + waiting: {}, }, }); + const payload = { roomId: "abc" }; - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("idle"); - expect(element.querySelector(".selected")?.textContent).toBe("false"); - expect(element.querySelector(".size")?.textContent).toBe("0"); - - expect($rootScope.session.send("select")).toBe(true); - - await wait(); - - expect(element.querySelector(".phase")?.textContent).toBe("selected"); - expect(element.querySelector(".selected")?.textContent).toBe("true"); - expect(element.querySelector(".size")?.textContent).toBe("1"); + expect(machine.can("join", payload)).toBe(true); + expect(machine.send("join", payload)).toEqual( + jasmine.objectContaining({ + ok: true, + status: "transitioned", + }), + ); + expect(contexts).toEqual([ + jasmine.objectContaining({ + type: "join", + from: "setup", + to: "waiting", + payload, + data: machine.data, + machine, + }), + jasmine.objectContaining({ + type: "join", + from: "setup", + to: "waiting", + payload, + data: machine.data, + machine, + }), + ]); }); - it("keeps an explicitly scoped machine usable after that scope is destroyed", () => { - const firstScope = $rootScope.$new(); - const machine = $machine(firstScope, { + it("blocks declarative transitions when policy denies without running hooks", () => { + const before = jasmine.createSpy("before"); + const update = jasmine.createSpy("update"); + const after = jasmine.createSpy("after"); + const denied = jasmine.createSpy("denied"); + const enter = jasmine.createSpy("enter"); + const exit = jasmine.createSpy("exit"); + const transitionHook = jasmine.createSpy("transition"); + const policyDecision = { + type: "deny" as const, + reason: "maintenance", + }; + const policy = jasmine.createSpy("policy").and.returnValue(policyDecision); + const payload = { roomId: "abc" }; + const machine = $machine({ + id: "session", initial: "setup", data: { - status: "idle", + roomId: "", }, - transitions: { + states: { setup: { - join(data) { - data.status = "waiting"; - return "waiting"; + on: { + join: { + to: "waiting", + guard({ payload: message }) { + return !!message.roomId; + }, + before, + update, + after, + denied, + }, }, }, + waiting: {}, + }, + hooks: { + enter: { + waiting: enter, + }, + exit: { + setup: exit, + }, + transition: transitionHook, + }, + policy, + metadata: { + feature: "session", }, }); - firstScope.session = machine; - expect(firstScope.session.matches("setup")).toBe(true); + expect(machine.can("join", payload)).toBe(false); - firstScope.$destroy(); + const result = machine.send("join", payload); - expect(machine.send("join")).toBe(true); - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("waiting"); + expect(result).toEqual( + jasmine.objectContaining({ + ok: false, + status: "policy-denied", + type: "join", + from: "setup", + to: "setup", + payload, + policyDecision, + }), + ); + expect(machine.current).toBe("setup"); + expect(machine.data.roomId).toBe(""); + expect(policy).toHaveBeenCalledTimes(2); + expect(policy.calls.allArgs()).toEqual([ + [ + jasmine.objectContaining({ + operation: "machine.transition", + target: "session", + machineId: "session", + type: "join", + from: "setup", + to: "waiting", + payload, + data: machine.data, + machine, + meta: { + feature: "session", + }, + metadata: { + feature: "session", + }, + }), + ], + [ + jasmine.objectContaining({ + operation: "machine.transition", + target: "session", + machineId: "session", + type: "join", + from: "setup", + to: "waiting", + payload, + data: machine.data, + machine, + meta: { + feature: "session", + }, + metadata: { + feature: "session", + }, + }), + ], + ]); + expect(before).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + expect(denied).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(enter).not.toHaveBeenCalled(); + expect(transitionHook).not.toHaveBeenCalled(); }); - it("keeps an explicitly scoped machine restorable after that scope is destroyed", () => { - const firstScope = $rootScope.$new(); - const machine = $machine(firstScope, { + it("rejects async machine policy decisions", () => { + const update = jasmine.createSpy("update"); + const machine = $machine({ initial: "setup", - data: { - status: "idle", + data: {}, + states: { + setup: { + on: { + join: { + to: "waiting", + update, + }, + }, + }, + waiting: {}, }, - transitions: {}, + policy: () => Promise.resolve("allow") as never, }); - firstScope.session = machine; - expect(firstScope.session.matches("setup")).toBe(true); + expect(() => machine.send("join")).toThrowError( + "$machine policy must return a synchronous decision.", + ); + expect(update).not.toHaveBeenCalled(); + expect(machine.current).toBe("setup"); + }); - firstScope.$destroy(); + it("normalizes string and invalid policy decisions", () => { + const createPolicyMachine = (policy) => + $machine({ + initial: "idle", + data: {}, + states: { + idle: { on: { start: { to: "ready" } } }, + ready: {}, + }, + policy, + }); - machine.restore({ - current: "waiting", - data: { - status: "restored", - }, - }); + const denied = createPolicyMachine(() => "deny"); + const allowed = createPolicyMachine(() => 42); - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("restored"); + expect(denied.send("start").status).toBe("policy-denied"); + expect(allowed.send("start").status).toBe("transitioned"); }); - it("keeps updating a surviving directive when another directive scope is destroyed", async () => { - const directiveScopes: ng.Scope[] = []; + it("rejects malformed transition descriptors", () => { + const malformed = [ + null, + { to: "" }, + {}, + { to: "ready", update: true }, + { to: "ready", guard: true }, + { to: "ready", before: true }, + { to: "ready", after: true }, + { to: "ready", denied: true }, + ]; - window.angular = new Angular(); - window.angular - .module("machineDirectiveApp", ["ng"]) - .directive("machinePanel", () => ({ - scope: true, - template: - '{{ session.current }}' + - '{{ session.data.count }}', - link(scope: ng.Scope) { - directiveScopes.push(scope); - scope.session.matches("idle"); + malformed.forEach((transition) => { + const machine = $machine({ + initial: "idle", + data: {}, + states: { + idle: { on: { start: transition } }, + ready: {}, }, - })); + }); - const injector = createInjector(["machineDirectiveApp"]); - const compile = injector.get("$compile") as ng.CompileService; - const machine = (injector.get("$machine") as MachineService)({ - initial: "idle", + expect(machine.send("start").status).toBe("missing-transition"); + }); + }); + + it("requires a states object", () => { + expect(() => + $machine({ + initial: "idle", + data: {}, + }), + ).toThrowError("$machine requires a states object."); + }); + + it("updates templates after declarative denied hooks mutate data", async () => { + const element = $compile( + '
{{ session.current }}' + + '{{ session.data.error }}
', + )($rootScope); + + $rootScope.session = $machine({ + initial: "setup", data: { - count: 0, + error: "", }, - transitions: { - idle: { - tick(data) { - data.count += 1; - return "idle"; + states: { + setup: { + on: { + join: { + to: "waiting", + guard() { + return false; + }, + denied({ data, payload }) { + data.error = payload.reason; + }, + }, }, }, + waiting: {}, }, }); - const rootScope = injector.get("$rootScope") as ng.RootScopeService; - - rootScope.session = machine; - - const element = compile( - '
' + - '
', - )(rootScope); await wait(); - expect(directiveScopes.length).toBe(2); - expect(element.querySelector(".first .count")?.textContent).toBe("0"); - expect(element.querySelector(".second .count")?.textContent).toBe("0"); + expect(element.querySelector(".mode")?.textContent).toBe("setup"); + expect(element.querySelector(".error")?.textContent).toBe(""); - directiveScopes[1].$destroy(); + const result = $rootScope.session.send("join", { + reason: "not_allowed", + }); - expect(machine.send("tick")).toBe(true); - expect(machine.data.count).toBe(1); + expect(result.status).toBe("guard-denied"); + expect($rootScope.session.current).toBe("setup"); + expect($rootScope.session.data.error).toBe("not_allowed"); await wait(); - expect(element.querySelector(".first .mode")?.textContent).toBe("idle"); - expect(element.querySelector(".first .count")?.textContent).toBe("1"); + expect(element.querySelector(".mode")?.textContent).toBe("setup"); + expect(element.querySelector(".error")?.textContent).toBe("not_allowed"); }); - it("restores a shared machine after the active directive scope is destroyed", async () => { - const directiveScopes: ng.Scope[] = []; - - window.angular = new Angular(); - window.angular - .module("machineDirectiveRestoreApp", ["ng"]) - .directive("machinePanel", () => ({ - scope: true, - template: - '{{ session.current }}' + - '{{ session.data.status }}', - link(scope: ng.Scope) { - directiveScopes.push(scope); - scope.session.matches("idle"); - }, - })); + it("keeps declarative update mutations visible when update throws", async () => { + const error = new Error("update failed"); + const element = $compile( + '
{{ session.current }}' + + '{{ session.data.status }}
', + )($rootScope); - const injector = createInjector(["machineDirectiveRestoreApp"]); - const compile = injector.get("$compile") as ng.CompileService; - const machine = (injector.get("$machine") as MachineService)({ - initial: "idle", + $rootScope.session = $machine({ + initial: "setup", data: { status: "idle", }, - transitions: {}, - }); - const rootScope = injector.get("$rootScope") as ng.RootScopeService; - - rootScope.session = machine; + states: { + setup: { + on: { + join: { + to: "waiting", + update({ data }) { + data.status = "joining"; - const element = compile( - '
' + - '
', - )(rootScope); + throw error; + }, + }, + }, + }, + waiting: {}, + }, + }); await wait(); - expect(directiveScopes.length).toBe(2); - expect(element.querySelector(".first .status")?.textContent).toBe("idle"); - expect(element.querySelector(".second .status")?.textContent).toBe("idle"); - - directiveScopes[1].$destroy(); - - machine.restore({ - current: "ready", - data: { - status: "restored", - }, - }); + expect(() => $rootScope.session.send("join")).toThrow(error); + expect($rootScope.session.current).toBe("setup"); + expect($rootScope.session.data.status).toBe("joining"); await wait(); - expect(element.querySelector(".first .mode")?.textContent).toBe("ready"); - expect(element.querySelector(".first .status")?.textContent).toBe( - "restored", - ); + expect(element.querySelector(".mode")?.textContent).toBe("setup"); + expect(element.querySelector(".status")?.textContent).toBe("joining"); }); - it("keeps updating a later directive when an older directive scope is destroyed", async () => { - const directiveScopes: ng.Scope[] = []; + it("does not run declarative hooks for missing transitions", () => { + const before = jasmine.createSpy("before"); + const update = jasmine.createSpy("update"); + const after = jasmine.createSpy("after"); + const denied = jasmine.createSpy("denied"); + const enter = jasmine.createSpy("enter"); + const exit = jasmine.createSpy("exit"); + const transitionHook = jasmine.createSpy("transition"); - window.angular = new Angular(); - window.angular - .module("machineDirectiveOlderApp", ["ng"]) - .directive("machinePanel", () => ({ - scope: true, - template: - '{{ session.current }}' + - '{{ session.data.count }}', - link(scope: ng.Scope) { - directiveScopes.push(scope); - scope.session.matches("idle"); + const machine = $machine({ + initial: "setup", + data: {}, + states: { + setup: { + on: { + join: { + to: "waiting", + before, + update, + after, + denied, + }, + }, }, - })); - - const injector = createInjector(["machineDirectiveOlderApp"]); - const compile = injector.get("$compile") as ng.CompileService; - const machine = (injector.get("$machine") as MachineService)({ - initial: "idle", - data: { - count: 0, + waiting: {}, }, - transitions: { - idle: { - tick(data) { - data.count += 1; - return "idle"; - }, + hooks: { + enter: { + waiting: enter, + }, + exit: { + setup: exit, }, + transition: transitionHook, }, }); - const rootScope = injector.get("$rootScope") as ng.RootScopeService; - - rootScope.session = machine; - - const element = compile( - '
' + - '
', - )(rootScope); - - await wait(); - - expect(directiveScopes.length).toBe(2); - - directiveScopes[0].$destroy(); - expect(machine.send("tick")).toBe(true); - - await wait(); - - expect(element.querySelector(".second .mode")?.textContent).toBe("idle"); - expect(element.querySelector(".second .count")?.textContent).toBe("1"); + expect(machine.send("missing")).toEqual( + jasmine.objectContaining({ + ok: false, + status: "missing-transition", + }), + ); + expect(before).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + expect(denied).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(enter).not.toHaveBeenCalled(); + expect(transitionHook).not.toHaveBeenCalled(); }); - it("keeps updating isolate directive bindings after a sibling directive scope is destroyed", async () => { - const directiveScopes: ng.Scope[] = []; - const directiveScopesByClass: Record = {}; + it("does not run hooks for invalid event names", () => { + const enter = jasmine.createSpy("enter"); + const exit = jasmine.createSpy("exit"); + const transitionHook = jasmine.createSpy("transition"); - window.angular = new Angular(); - window.angular - .module("machineDirectiveIsolateApp", ["ng"]) - .directive("machinePanel", () => ({ - scope: { - session: "=", - }, - template: - '{{ session.current }}' + - '{{ session.data.count }}', - link(scope: ng.Scope, element: Element) { - directiveScopes.push(scope); - directiveScopesByClass[element.className] = scope; - scope.session.matches("idle"); + const machine = $machine({ + initial: "setup", + data: {}, + states: { + setup: { + on: { + join: { + to: "waiting", + }, + }, }, - })); - - const injector = createInjector(["machineDirectiveIsolateApp"]); - const compile = injector.get("$compile") as ng.CompileService; - const machine = (injector.get("$machine") as MachineService)({ - initial: "idle", - data: { - count: 0, + waiting: {}, }, - transitions: { - idle: { - tick(data) { - data.count += 1; - return "idle"; - }, + hooks: { + enter: { + waiting: enter, + }, + exit: { + setup: exit, }, + transition: transitionHook, }, }); - const rootScope = injector.get("$rootScope") as ng.RootScopeService; - - rootScope.session = machine; - - const element = compile( - '
' + - '
', - )(rootScope); - - await wait(); - - expect(directiveScopes.length).toBe(2); - expect(element.querySelector(".first .count")?.textContent).toBe("0"); - expect(element.querySelector(".second .count")?.textContent).toBe("0"); - - directiveScopesByClass.second.$destroy(); - - expect(machine.send("tick")).toBe(true); - await wait(); - - expect(element.querySelector(".first .mode")?.textContent).toBe("idle"); - expect(element.querySelector(".first .count")?.textContent).toBe("1"); + expect( + (machine as unknown as { send(type: unknown): unknown }).send(null), + ).toEqual( + jasmine.objectContaining({ + ok: false, + status: "invalid-event", + }), + ); + expect(exit).not.toHaveBeenCalled(); + expect(enter).not.toHaveBeenCalled(); + expect(transitionHook).not.toHaveBeenCalled(); }); - it("runs hooks on named machines registered through module.machine", () => { - const calls: string[] = []; - + it("lets an injectable workflow-owned gate use the state-tree machine API", () => { window.angular = new Angular(); window.angular - .module("namedMachineHookApp", ["ng"]) - .machine("sessionMachine", { - initial: "setup", - data: { - status: "idle", - }, - transitions: { - setup: { - join() { - return "waiting"; + .module("workflowMachineGateApp", ["ng"]) + .factory("sessionWorkflowMachine", [ + "$machine", + ($machine: ng.MachineService) => + $machine({ + initial: "idle", + data: { + token: "", }, - }, - }, - hooks: { - enter: { - waiting(context) { - context.data.status = "waiting"; - calls.push(`${context.from}->${context.to}`); + states: { + idle: { + on: { + start: { + to: "authenticating", + }, + }, + }, + authenticating: { + on: { + authenticated: { + to: "ready", + update({ data, payload }) { + data.token = payload; + }, + }, + }, + }, + ready: {}, }, - }, - }, - }); - - const injector = createInjector(["namedMachineHookApp"]); - const machine = injector.get("sessionMachine") as ng.Machine<{ - status: string; - }>; - - expect(machine.send("join")).toBe(true); - - expect(machine.current).toBe("waiting"); - expect(machine.data.status).toBe("waiting"); - expect(calls).toEqual(["setup->waiting"]); + }), + ]); + + const injector = createInjector(["workflowMachineGateApp"]); + const machine = injector.get("sessionWorkflowMachine") as ng.Machine< + { token: string }, + { start: undefined; authenticated: string } + >; + + expect(machine.current).toBe("idle"); + expect(machine.send("start").ok).toBe(true); + expect(machine.current).toBe("authenticating"); + expect(machine.send("authenticated", "abc").ok).toBe(true); + expect(machine.current).toBe("ready"); + expect(machine.data.token).toBe("abc"); }); }); diff --git a/src/services/machine/machine.ts b/src/services/machine/machine.ts index 1272a4a38..0035aa8c5 100644 --- a/src/services/machine/machine.ts +++ b/src/services/machine/machine.ts @@ -10,9 +10,14 @@ import { isFunction, isInstanceOf, isObject, + isPromiseLike, isString, keys, } from "../../shared/utils.ts"; +import type { + PolicyContext, + PolicyDecision, +} from "../../core/policy/policy.ts"; export type MachineMode = string; @@ -29,167 +34,355 @@ type MachineSendPayload< ? [payload?: TEvents[TType]] : [payload: TEvents[TType]]; -type MachineTransitionTable< +export type MachineTransitionPolicyDecisionType = "allow" | "deny"; + +export type MachineReadonlyMachine< TData extends object, TEvents extends object, -> = string extends keyof TEvents - ? Partial< - Record< - string, - MachineTransitionDefinition - > - > - : Partial<{ - [TType in MachineEventName]: MachineTransitionDefinition< - TData, - TEvents[TType], - TEvents - >; - }>; + TMode extends MachineMode = MachineMode, +> = Omit, "data"> & { + readonly data: Readonly; +}; -export type MachineTransitionResult = MachineMode | false | undefined; - -export type MachineTransition< +export interface MachineTransitionPolicyContext< TData extends object = Record, + TEvents extends object = MachineEventMap, TPayload = unknown, - TEvents extends object = MachineNoEvents, + TMode extends MachineMode = MachineMode, +> extends PolicyContext { + operation: "machine.transition"; + machineId?: string; + type: string; + from: TMode; + to: TMode; + payload: TPayload; + data: Readonly; + machine: MachineReadonlyMachine; + metadata?: Record; +} + +export type MachineTransitionPolicyDecision = + PolicyDecision; + +export type MachineTransitionPolicy< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, > = ( - data: TData, - payload: TPayload, - machine: Machine, -) => MachineTransitionResult; + context: MachineTransitionPolicyContext< + TData, + TEvents, + TEvents extends Record ? TPayload : unknown, + TMode + >, +) => MachineTransitionPolicyDecision | MachineTransitionPolicyDecisionType; + +export interface MachineEventTransitionContext< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TPayload = unknown, + TMode extends MachineMode = MachineMode, +> { + type: string; + from: TMode; + to?: TMode; + payload: TPayload; + data: TData; + machine: Machine; +} -export type MachineGuard< +export type MachineEventTransitionGuardContext< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TPayload = unknown, + TMode extends MachineMode = MachineMode, +> = Omit< + MachineEventTransitionContext, + "data" | "machine" +> & { + readonly data: Readonly; + readonly machine: MachineReadonlyMachine; +}; + +export type MachineEventTransitionGuard< TData extends object = Record, TPayload = unknown, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, > = ( - data: TData, - payload: TPayload, - machine: Machine, + context: MachineEventTransitionGuardContext, ) => boolean; -export interface MachineTransitionDescriptor< +export type MachineEventTransitionUpdate< TData extends object = Record, TPayload = unknown, - TEvents extends object = MachineNoEvents, -> { - guard?: MachineGuard; - target: MachineTransition; -} + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = ( + context: MachineEventTransitionContext, +) => void; -export type MachineTransitionDefinition< +export type MachineEventTransitionHook< TData extends object = Record, TPayload = unknown, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = ( + context: MachineEventTransitionContext, +) => void; + +export type MachineEventTransitionConfig< + TData extends object = Record, + TPayload = unknown, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, + TFrom extends TMode = TMode, > = - | MachineTransition - | MachineTransitionDescriptor; + | { + guard?: MachineEventTransitionGuard; + before?: MachineEventTransitionHook; + after?: MachineEventTransitionHook; + denied?: MachineEventTransitionHook; + to: TMode; + update?: MachineEventTransitionUpdate; + } + | { + guard?: MachineEventTransitionGuard; + before?: MachineEventTransitionHook; + after?: MachineEventTransitionHook; + denied?: MachineEventTransitionHook; + to?: TFrom; + update: MachineEventTransitionUpdate; + }; + +export type MachineStateTransitionMap< + TData extends object, + TEvents extends object, + TMode extends MachineMode, + TFrom extends TMode, +> = string extends keyof TEvents + ? Partial< + Record< + string, + MachineEventTransitionConfig< + TData, + TEvents[string], + TEvents, + TMode, + TFrom + > + > + > + : Partial<{ + [TType in MachineEventName]: MachineEventTransitionConfig< + TData, + TEvents[TType], + TEvents, + TMode, + TFrom + >; + }>; -export type MachineTransitionMap< +export interface MachineStateDefinition< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, + TFrom extends TMode = TMode, +> { + on?: MachineStateTransitionMap; +} + +export type MachineStateMap< TData extends object, - TEvents extends object = MachineNoEvents, -> = Partial< - Record | undefined> + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = { + [TFrom in TMode]: MachineStateDefinition; +}; + +type MachineEventNamesFromStates = { + [TMode in keyof TStates]: TStates[TMode] extends { on?: infer TOn } + ? Extract + : never; +}[keyof TStates]; + +type MachineEventsFromStates = Record< + MachineEventNamesFromStates, + unknown >; -export interface MachineTransitionContext< +type InferredMachineConfig< + TData extends object, + TStates extends Record>, +> = Omit< + MachineConfig< + TData, + MachineEventsFromStates, + Extract + >, + "initial" | "states" +> & { + initial: Extract; + states: TStates & + MachineStateMap< + TData, + MachineEventsFromStates, + Extract + >; +}; + +export interface MachineConfig< TData extends object = Record, - TEvents extends object = MachineNoEvents, - TPayload = unknown, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, > { - type: string; - from: MachineMode; - to: MachineMode; - payload: TPayload; + id?: string; + initial: TMode; data: TData; - machine: Machine; + states: MachineStateMap; + hooks?: MachineHooks; + policy?: MachineTransitionPolicy; + metadata?: Record; } -type MachineTransitionContextUnion< - TData extends object, - TEvents extends object, -> = string extends keyof TEvents - ? MachineTransitionContext - : { - [TType in MachineEventName]: MachineTransitionContext< - TData, - TEvents, - TEvents[TType] - > & { - type: TType; - }; - }[MachineEventName]; +export type MachineDataOf = TMachine extends { + data: infer TData extends object; +} + ? TData + : TMachine extends MachineConfig + ? TData + : never; + +export type MachineEventsOf = + TMachine extends Machine + ? TEvents + : TMachine extends MachineConfig + ? TEvents + : MachineEventMap; + +export type MachineEventNamesOf = Extract< + keyof MachineEventsOf, + string +>; -export type MachineTransitionHook< +export type MachineModesOf = + TMachine extends MachineConfig< + object, + object, + infer TMode extends MachineMode + > + ? TMode + : TMachine extends { states: infer TStates } + ? Extract + : MachineMode; + +export type MachineGlobalTransitionHook< TData extends object = Record, - TEvents extends object = MachineNoEvents, -> = (context: MachineTransitionContextUnion) => void; + TEvents extends object = MachineEventMap, +> = ( + context: string extends keyof TEvents + ? MachineEventTransitionContext + : { + [TType in MachineEventName]: MachineEventTransitionContext< + TData, + TEvents, + TEvents[TType] + > & { + type: TType; + }; + }[MachineEventName], +) => void; export type MachineModeHooks< TData extends object = Record, - TEvents extends object = MachineNoEvents, -> = Partial>>; + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = Partial>>; export interface MachineHooks< TData extends object = Record, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, > { - enter?: MachineModeHooks; - exit?: MachineModeHooks; - transition?: MachineTransitionHook; + enter?: MachineModeHooks; + exit?: MachineModeHooks; + transition?: MachineGlobalTransitionHook; } -export interface MachineConfig< +export interface MachineSnapshot< TData extends object = Record, - TEvents extends object = MachineNoEvents, + TMode extends MachineMode = MachineMode, > { - initial: MachineMode; - data: TData; - transitions: MachineTransitionMap; - hooks?: MachineHooks; + readonly current: TMode; + readonly data: TData; } -export interface MachineSnapshot< +export type MachineSendStatus = + | "transitioned" + | "updated" + | "missing-transition" + | "guard-denied" + | "policy-denied" + | "invalid-event"; + +type MachineSendResultBase< TData extends object = Record, -> { - current: MachineMode; - data: TData; -} + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = { + readonly type: string; + readonly from: TMode; + readonly to: TMode; + readonly data: TData; + readonly payload: unknown; + readonly machine: Machine; + readonly policyDecision?: MachineTransitionPolicyDecision; +}; + +export type MachineSendResult< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +> = MachineSendResultBase & + ( + | { + readonly ok: true; + readonly status: Extract; + } + | { + readonly ok: false; + readonly status: Exclude; + } + ); export interface Machine< TData extends object = Record, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, > { - current: MachineMode; + readonly current: TMode; data: TData; send>( type: TType, ...payload: MachineSendPayload - ): boolean; + ): MachineSendResult; can>( type: TType, - payload?: TEvents[TType], + ...payload: MachineSendPayload ): boolean; - matches(mode: MachineMode): boolean; - snapshot(): MachineSnapshot; - restore(snapshot: MachineSnapshot): void; + matches(mode: TMode): boolean; + snapshot(): MachineSnapshot; + restore(snapshot: MachineSnapshot): void; } export interface MachineService { < TData extends object = Record, - TEvents extends object = MachineNoEvents, - >( - config: MachineConfig, - ): Machine; - < - TData extends object = Record, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, >( - scope: ng.Scope, - config: MachineConfig, - ): Machine; + config: MachineConfig, + ): Machine; } type MachineTarget = Machine< @@ -198,9 +391,13 @@ type MachineTarget = Machine< > & ScopeProxyBindable; -interface MachineArgs { +interface MachineArgs< + TData extends object, + TEvents extends object, + TMode extends MachineMode, +> { _scope?: ng.Scope; - _config: MachineConfig; + _config: MachineConfig; } interface MachineBinding { @@ -208,137 +405,131 @@ interface MachineBinding { _proxy: Machine; } -interface ResolvedMachineTransition< +interface ResolvedMachineStateTransition< TData extends object, TEvents extends object, > { - _guard?: MachineGuard; - _target: MachineTransition; + _to?: MachineMode; + _guard?: MachineEventTransitionGuard; + _before?: MachineEventTransitionHook; + _update?: MachineEventTransitionUpdate; + _after?: MachineEventTransitionHook; + _denied?: MachineEventTransitionHook; } -/** - * Provides reactive mode machines backed by AngularTS scope proxies. - */ -export class MachineProvider { - $get = (): MachineService => createMachine as MachineService; +/** @internal */ +export function createMachineService(): MachineService { + return createMachine as MachineService; } export function defineMachine< TData extends object = Record, - TEvents extends object = MachineNoEvents, ->(config: MachineConfig): MachineConfig { + const TStates extends Record> = Record< + string, + MachineStateDefinition + >, +>( + config: InferredMachineConfig, +): MachineConfig< + TData, + MachineEventsFromStates, + Extract +>; +export function defineMachine< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +>( + config: MachineConfig, +): MachineConfig; +export function defineMachine< + TData extends object = Record, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, +>( + config: MachineConfig, +): MachineConfig { return config; } function createMachine< TData extends object, - TEvents extends object = MachineNoEvents, + TEvents extends object = MachineEventMap, + TMode extends MachineMode = MachineMode, >( - scopeOrConfig: ng.Scope | MachineConfig, - maybeConfig?: MachineConfig, -): Machine { - const { _scope: scope, _config: config } = normalizeMachineArgs( + scopeOrConfig: ng.Scope | MachineConfig, + maybeConfig?: MachineConfig, +): Machine { + const { _scope: scope, _config: typedConfig } = normalizeMachineArgs( scopeOrConfig, maybeConfig, ); - assertMachineConfig(config); + assertMachineConfig(typedConfig as unknown as MachineConfig); + const config = typedConfig as unknown as MachineConfig; const rawData = config.data; + let currentMode = config.initial; let activeBinding: MachineBinding | undefined; const bindings = new Map>(); const machineTarget: MachineTarget = { - current: config.initial, + get current() { + return currentMode; + }, data: rawData, - send(type: string, payload?: unknown): boolean { + send(type: string, payload?: unknown): MachineSendResult { + if (!isString(type)) { + return createSendResult("invalid-event", "", payload, false); + } + + return dispatchMachineStateTransition(type, payload, config); + }, + can(type: string, payload?: unknown): boolean { if (!isString(type)) { return false; } - const transition = getTransition(machineTarget.current, config, type); + const transition = getStateTransition(currentMode, config, type); if (!transition) { return false; } - const binding = getActiveBinding(); - - return batch(binding?._handler, () => { - let transitionStarted = false; - - try { - const activeMachine = getActiveMachine(); - - if (!canRunTransition(transition, payload, activeMachine)) { - return false; - } - - const from = machineTarget.current; + const activeMachine = getActiveMachine(); + const from = currentMode; + const context = createStateTransitionContext( + transition, + type, + from, + payload, + activeMachine, + ); - transitionStarted = true; + if (!canRunStateTransition(transition, context)) { + return false; + } - const nextMode = transition._target( - activeMachine.data, - payload, - activeMachine, - ); - const to = isNonEmptyString(nextMode) ? nextMode : from; - const context: MachineTransitionContext = { + return !isMachinePolicyDenied( + checkMachineTransitionPolicy( + config, + createTransitionPolicyContext( + config, type, from, - to, + context.to ?? from, payload, - data: activeMachine.data, - machine: activeMachine, - }; - const hookContext = context as MachineTransitionContextUnion< - TData, - TEvents - >; - - if (from !== to) { - getModeHook(config.hooks?.exit, from)?.(hookContext); - } - - if (isNonEmptyString(nextMode)) { - machineTarget.current = nextMode; - } else { - machineTarget.current = from; - } - - if (from !== to) { - getModeHook(config.hooks?.enter, to)?.(hookContext); - } - - getTransitionHook(config.hooks)?.(hookContext); - - return true; - } finally { - if (transitionStarted) { - scheduleMachineBindings(); - } - } - }); - }, - can(type: string, payload?: unknown): boolean { - if (!isString(type)) { - return false; - } - - const transition = getTransition(machineTarget.current, config, type); - - return ( - !!transition && - canRunTransition(transition, payload, getActiveMachine()) + activeMachine, + ), + ), ); }, matches(mode: MachineMode): boolean { - return machineTarget.current === mode; + return currentMode === mode; }, snapshot(): MachineSnapshot { return { - current: machineTarget.current, + current: currentMode, data: cloneMachineData(rawData), }; }, @@ -350,7 +541,7 @@ function createMachine< batch(binding?._handler, () => { const previousDataKeys = collectMachineDataKeys(rawData); - machineTarget.current = snapshot.current; + currentMode = snapshot.current; restoreMachineData(rawData, snapshot.data); scheduleMachineBindings(previousDataKeys); }); @@ -374,13 +565,13 @@ function createMachine< }); if (scope?.$handler) { - return createScope(machineTarget, scope.$handler as Scope) as Machine< - TData, - TEvents - >; + return createScope( + machineTarget, + scope.$handler as Scope, + ) as unknown as Machine; } - return machineTarget; + return machineTarget as Machine; function getActiveMachine(): Machine { return getActiveBinding()?._proxy ?? machineTarget; @@ -428,6 +619,225 @@ function createMachine< } } } + + function dispatchMachineStateTransition( + type: string, + payload: unknown, + stateConfig: MachineConfig, + ): MachineSendResult { + const transition = getStateTransition(currentMode, stateConfig, type); + + if (!transition) { + return createSendResult("missing-transition", type, payload, false); + } + + const binding = getActiveBinding(); + + return batch(binding?._handler, () => { + let transitionStarted = false; + + try { + const activeMachine = getActiveMachine(); + const from = machineTarget.current; + const context = createStateTransitionContext( + transition, + type, + from, + payload, + activeMachine, + ); + + if (!canRunStateTransition(transition, context)) { + transitionStarted = !!transition._denied; + transition._denied?.(context); + + return createSendResult( + "guard-denied", + type, + payload, + false, + from, + from, + activeMachine, + ); + } + + const policyDecision = checkMachineTransitionPolicy( + stateConfig, + createTransitionPolicyContext( + stateConfig, + type, + from, + context.to ?? from, + payload, + activeMachine, + ), + ); + + if (isMachinePolicyDenied(policyDecision)) { + return createSendResult( + "policy-denied", + type, + payload, + false, + from, + from, + activeMachine, + policyDecision, + ); + } + + transitionStarted = true; + + transition._before?.(context); + transition._update?.(context); + + const to = context.to ?? from; + const hookContext = context as Parameters< + MachineGlobalTransitionHook + >[0]; + + if (from !== to) { + getModeHook(stateConfig.hooks?.exit, from)?.(hookContext); + } + + currentMode = to; + + if (from !== to) { + getModeHook(stateConfig.hooks?.enter, to)?.(hookContext); + } + + transition._after?.(context); + getTransitionHook(stateConfig.hooks)?.(hookContext); + + return createSendResult( + from === to ? "updated" : "transitioned", + type, + payload, + true, + from, + to, + activeMachine, + ); + } finally { + if (transitionStarted) { + scheduleMachineBindings(); + } + } + }); + } + + function createStateTransitionContext( + transition: ResolvedMachineStateTransition, + type: string, + from: MachineMode, + payload: unknown, + activeMachine: Machine, + ): MachineEventTransitionContext { + return { + type, + from, + to: transition._to ?? from, + payload, + data: activeMachine.data, + machine: activeMachine, + }; + } + + function createSendResult( + status: MachineSendStatus, + type: string, + payload: unknown, + ok: boolean, + from: MachineMode = currentMode, + to: MachineMode = from, + activeMachine: Machine = getActiveMachine(), + policyDecision?: MachineTransitionPolicyDecision, + ): MachineSendResult { + return { + ok, + status, + type, + from, + to, + data: activeMachine.data, + payload, + machine: activeMachine, + policyDecision, + } as MachineSendResult; + } +} + +const ALLOW_MACHINE_TRANSITION: MachineTransitionPolicyDecision = { + type: "allow", +}; + +function createTransitionPolicyContext< + TData extends object, + TEvents extends object, +>( + config: MachineConfig, + type: string, + from: MachineMode, + to: MachineMode, + payload: unknown, + activeMachine: Machine, +): MachineTransitionPolicyContext { + const metadata = config.metadata; + + return { + operation: "machine.transition", + target: config.id, + machineId: config.id, + type, + from, + to, + payload, + data: activeMachine.data, + machine: activeMachine, + meta: metadata, + metadata, + }; +} + +function checkMachineTransitionPolicy< + TData extends object, + TEvents extends object, +>( + config: MachineConfig, + context: MachineTransitionPolicyContext, +): MachineTransitionPolicyDecision { + if (!config.policy) { + return ALLOW_MACHINE_TRANSITION; + } + + const decision = config.policy( + context as MachineTransitionPolicyContext< + TData, + TEvents, + TEvents extends Record ? TPayload : unknown + >, + ); + + if (isPromiseLike(decision)) { + throw new Error("$machine policy must return a synchronous decision."); + } + + if (isString(decision)) { + return { type: decision }; + } + + if (!isObject(decision) || !isString(decision.type)) { + return ALLOW_MACHINE_TRANSITION; + } + + return decision; +} + +function isMachinePolicyDenied( + decision: MachineTransitionPolicyDecision, +): boolean { + return decision.type === "deny"; } function collectMachineDataKeys(value: object): string[] { @@ -577,67 +987,91 @@ function setMachineDataProperty( target[key] = value; } -function getTransition( +function getStateTransition( mode: MachineMode, config: MachineConfig, type: string, -): ResolvedMachineTransition | undefined { - if (!hasOwn(config.transitions, mode)) { +): ResolvedMachineStateTransition | undefined { + if (!hasOwn(config.states, mode)) { return undefined; } - const transitions = config.transitions[mode]; + const state = config.states[mode]; - if (!isObject(transitions) || !hasOwn(transitions, type)) { + if (!isObject(state) || !isObject(state.on) || !hasOwn(state.on, type)) { return undefined; } - const transition = (transitions as Record)[type]; + const transition = (state.on as Record)[type]; - return resolveMachineTransition(transition); + return resolveMachineStateTransition(transition); } -function resolveMachineTransition( +function resolveMachineStateTransition< + TData extends object, + TEvents extends object, +>( transition: unknown, -): ResolvedMachineTransition | undefined { - if (isFunction(transition)) { - return { - _target: transition as MachineTransition, - }; - } - - if (!isPlainObject(transition) || !hasOwn(transition, "target")) { +): ResolvedMachineStateTransition | undefined { + if (!isPlainObject(transition)) { return undefined; } const descriptor = transition as Partial< - MachineTransitionDescriptor + MachineEventTransitionConfig >; + const hasTo = hasOwn(transition, "to"); + const hasUpdate = hasOwn(transition, "update"); + + if (hasTo && !isNonEmptyString(descriptor.to)) { + return undefined; + } + + if (!hasTo && !isFunction(descriptor.update)) { + return undefined; + } + + if (hasUpdate && !isFunction(descriptor.update)) { + return undefined; + } + + if (descriptor.guard !== undefined && !isFunction(descriptor.guard)) { + return undefined; + } + + if (descriptor.before !== undefined && !isFunction(descriptor.before)) { + return undefined; + } - if (!isFunction(descriptor.target)) { + if (descriptor.after !== undefined && !isFunction(descriptor.after)) { + return undefined; + } + + if (descriptor.denied !== undefined && !isFunction(descriptor.denied)) { return undefined; } return { + _to: isNonEmptyString(descriptor.to) ? descriptor.to : undefined, _guard: isFunction(descriptor.guard) ? descriptor.guard : undefined, - _target: descriptor.target, + _before: isFunction(descriptor.before) ? descriptor.before : undefined, + _update: isFunction(descriptor.update) ? descriptor.update : undefined, + _after: isFunction(descriptor.after) ? descriptor.after : undefined, + _denied: isFunction(descriptor.denied) ? descriptor.denied : undefined, }; } -function canRunTransition( - transition: ResolvedMachineTransition, - payload: unknown, - machine: Machine, +function canRunStateTransition( + transition: ResolvedMachineStateTransition, + context: MachineEventTransitionContext, ): boolean { - return transition._guard - ? transition._guard(machine.data, payload, machine) - : true; + return transition._guard ? transition._guard(context) : true; } function getModeHook( hooks: MachineModeHooks | undefined, mode: MachineMode, -): MachineTransitionHook | undefined { +): MachineGlobalTransitionHook | undefined { if (!hooks || !hasOwn(hooks, mode)) { return undefined; } @@ -649,7 +1083,7 @@ function getModeHook( function getTransitionHook( hooks: MachineHooks | undefined, -): MachineTransitionHook | undefined { +): MachineGlobalTransitionHook | undefined { if (!hooks || !hasOwn(hooks, "transition")) { return undefined; } @@ -665,10 +1099,14 @@ function batch(scope: Scope | undefined, fn: () => T): T { return scope.$batch(fn); } -function normalizeMachineArgs( - scopeOrConfig: ng.Scope | MachineConfig, - maybeConfig?: MachineConfig, -): MachineArgs { +function normalizeMachineArgs< + TData extends object, + TEvents extends object, + TMode extends MachineMode, +>( + scopeOrConfig: ng.Scope | MachineConfig, + maybeConfig?: MachineConfig, +): MachineArgs { if (maybeConfig) { return { _scope: scopeOrConfig as ng.Scope, @@ -677,7 +1115,7 @@ function normalizeMachineArgs( } return { - _config: scopeOrConfig as MachineConfig, + _config: scopeOrConfig as MachineConfig, }; } @@ -696,8 +1134,8 @@ function assertMachineConfig( throw new Error("$machine requires a data object."); } - if (!isObject(config.transitions)) { - throw new Error("$machine requires a transitions object."); + if (!isObject(config.states)) { + throw new Error("$machine requires a states object."); } assertMachineHooks(config.hooks); diff --git a/src/services/pubsub/EVENTBUS_IMPLEMENTATION_ROADMAP.md b/src/services/pubsub/EVENTBUS_IMPLEMENTATION_ROADMAP.md deleted file mode 100644 index bac4a065f..000000000 --- a/src/services/pubsub/EVENTBUS_IMPLEMENTATION_ROADMAP.md +++ /dev/null @@ -1,366 +0,0 @@ -# EventBus Implementation Roadmap - -This roadmap brings `$eventBus` and its directives into the AngularTS level-9 -reactivity and lifecycle policy without turning the service into a state -manager. - -`$eventBus` should remain a small application-wide pub/sub primitive. The -hardening point is context: when the subscription context is an AngularTS scope -proxy, the context owns the listener lifecycle. - -## Goal - -Make `$eventBus.subscribe(topic, listener, context?)` lifecycle-aware while -preserving the existing public method. - -Target behavior: - -```ts -const off = $eventBus.subscribe( - "cart:item-added", - function (item) { - this.cartCount += 1; - }, - $scope, -); -``` - -When `context` is a scope proxy: - -- listener `this` is the scope -- the listener is automatically removed on `$destroy` -- the returned `off` function still unsubscribes manually -- double cleanup is safe -- destroyed scopes do not receive queued async delivery - -When `context` is not a scope proxy: - -- existing callback binding behavior remains unchanged -- caller remains responsible for cleanup - -## Non-Goals - -- Do not add a separate `subscribeScoped()` public API. -- Do not make `$eventBus` a general state store. -- Do not add global reactive state to `$eventBus`. -- Do not make scope events obsolete. -- Do not remove `subscribe()` or `subscribeOnce()` compatibility. -- Do not make `$eventBusProvider` the normal user path. - -## Public Contract - -`context` has two meanings: - -- callback binding for all values -- lifecycle owner when the value is an AngularTS scope proxy - -This lets AngularTS infer lifecycle responsibility from a primitive users -already pass. - -## Slice 1: Current Behavior Inventory - -Document and test the current public behavior before changing internals. - -Scope: - -- `src/services/pubsub/pubsub.ts` -- `src/services/pubsub/pubsub.spec.ts` -- `src/directive/channel/channel.ts` -- `src/directive/channel/channel.spec.ts` -- `docs/content/docs/services/pubsub.md` -- `docs/content/docs/directive/channel.md` - -Acceptance: - -- inventory notes current `subscribe`, `subscribeOnce`, `unsubscribe`, - `publish`, `reset`, `dispose`, and `getCount` behavior -- docs identify `$eventBus` as a cross-boundary utility, not scope-tree - communication -- `ng-channel` current manual cleanup is documented as implementation detail -- no runtime behavior changes - -Verification: - -```bash -npx playwright test src/services/pubsub/pubsub.test.ts -npx playwright test src/directive/channel/channel.spec.ts -make docs-examples-check -``` - -## Slice 2: Harden Listener Types - -Make context typing express callback binding without changing runtime behavior. - -Target type shape: - -```ts -type PubSubListener = ( - this: TContext, - ...args: unknown[] -) => unknown; - -subscribe(topic: string, fn: PubSubListener): () => boolean; - -subscribe( - topic: string, - fn: PubSubListener, - context: TContext, -): () => boolean; -``` - -Apply the same context typing to `subscribeOnce`. - -Acceptance: - -- existing unbound listener calls still typecheck -- context-bound listeners infer `this` -- scope context listeners typecheck without casting -- invalid listener/context assumptions fail type checks where TypeScript can - express them -- no runtime behavior changes - -Verification: - -```bash -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 3: Scope Context Auto-Unsubscribe - -Teach `subscribe()` that a scope proxy context is a lifecycle owner. - -Implementation rules: - -- use `isProxy(context)` to detect AngularTS scope proxies -- subscribe normally with the same context -- register cleanup through `context.$on("$destroy", unsubscribe)` -- return one cleanup function that removes both the event listener and the - `$destroy` listener -- cleanup must be idempotent -- `getCount(topic)` must not count destroyed scope listeners after cleanup - -Acceptance: - -- `subscribe(topic, fn, $scope)` auto-unsubscribes on `$destroy` -- calling the returned cleanup before `$destroy` unregisters the `$destroy` - hook -- calling cleanup after `$destroy` is safe -- non-scope context behavior is unchanged -- unbound listener behavior is unchanged - -Verification: - -```bash -npx playwright test src/services/pubsub/pubsub.test.ts -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 4: Queued Delivery Safety - -Ensure destroyed scope contexts do not receive queued async delivery. - -Problem: - -- `publish()` snapshots listeners and delivers them in a microtask. -- A scope can be destroyed after `publish()` snapshots listeners but before the - microtask runs. -- Scope-owned listeners should not run after their lifecycle owner is - destroyed. - -Design options: - -- mark listener entries inactive on unsubscribe and skip inactive snapshot - entries -- or check current registration before invoking a snapshot entry - -Decision: - -- preserve existing async publish ordering for active listeners -- skip inactive/destroyed scope-owned listeners before invocation -- keep generic unsubscribe-after-publish semantics explicit in tests - -Acceptance: - -- a scope destroyed before the publish microtask does not receive the event -- active listeners still receive events in subscription order -- one listener throwing still routes to `$exceptionHandler` and does not block - later active listeners - -Verification: - -```bash -npx playwright test src/services/pubsub/pubsub.test.ts -``` - -## Slice 5: `subscribeOnce()` Scope Context - -Apply the same scope lifecycle semantics to `subscribeOnce()`. - -Rules: - -- `subscribeOnce(topic, fn, context)` must pass context through the internal - subscription path -- scope destruction before first delivery removes the one-time listener -- first delivery removes both listener and `$destroy` hook -- callback `this` remains the provided context - -Acceptance: - -- `subscribeOnce(topic, fn, $scope)` auto-cleans on `$destroy` -- `subscribeOnce(topic, fn, $scope)` runs at most once -- no lingering `$destroy` listener after first delivery -- existing non-scope `subscribeOnce` behavior is preserved - -Verification: - -```bash -npx playwright test src/services/pubsub/pubsub.test.ts -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 6: Migrate `ng-channel` - -Make `ng-channel` use the hardened context behavior instead of manually owning -cleanup. - -Current pattern: - -```ts -const unsubscribe = $eventBus.subscribe(channel, listener); -scope.$on("$destroy", () => unsubscribe()); -``` - -Target pattern: - -```ts -$eventBus.subscribe(channel, listener, scope); -``` - -Rules: - -- directive behavior remains unchanged -- template payloads still merge object payloads into scope -- string payloads still replace `innerHTML` when there is no template content -- normalized `data-ng-channel` aliases still work -- destroyed directive scopes stop receiving queued events - -Acceptance: - -- `ng-channel` calls `$eventBus.subscribe(channel, listener, scope)` -- directive no longer manually registers `$destroy` cleanup -- existing directive tests pass after expectation update -- add test for publish followed by scope destroy before microtask delivery - -Verification: - -```bash -npx playwright test src/directive/channel/channel.spec.ts -``` - -## Slice 7: Diagnostics Without State Inflation - -Keep diagnostics small and implementation-oriented. - -Allowed: - -- retain `getCount(topic)` as active listener count -- add internal test helpers only if needed -- document cleanup behavior - -Defer unless proven necessary: - -- public scoped listener count -- public topic listing -- leak reports -- reactive diagnostics - -Acceptance: - -- no new public diagnostic API unless tests show a real user need -- leak prevention is proven through behavior tests -- `$eventBus` stays classified as a utility service - -Verification: - -```bash -npx playwright test src/services/pubsub/pubsub.test.ts -``` - -## Slice 8: Docs And Samples - -Update docs to teach context hardening as the normal scope-owned path. - -Docs to update: - -- `docs/content/docs/services/pubsub.md` -- `docs/content/docs/service/eventBus.md` -- `docs/content/docs/directive/channel.md` -- `docs/content/docs/provider/eventBusProvider.md` - -Rules: - -- teach `$eventBus.subscribe(topic, listener, $scope)` for controllers, - directives, and components -- teach `$eventBus.subscribe(topic, listener)` for long-lived services and - external integrations -- keep scope events recommended for parent/child scope-tree communication -- mark `$eventBusProvider` as advanced/internal replacement only, not normal app - authoring -- include one docs sample where scope destruction auto-cleans the subscription - -Acceptance: - -- docs no longer require manual `$scope.$on("$destroy", unsubscribe)` for - scope-owned `$eventBus` subscriptions -- docs explain that scope context means callback binding plus lifecycle - ownership -- docs examples pass API reference checks - -Verification: - -```bash -make docs-examples-check -make doc -``` - -## Slice 9: Public Surface And Generated Docs - -Apply the level-9 documentation requirement for any type changes. - -Rules: - -- update TypeDoc comments on `PubSub` and `PubSubProvider` -- update public inventory when it exists -- classify `PubSubProvider` as config-free or advanced/legacy unless provider - inventory proves real user-facing configuration -- do not expose new implementation-only listener entry types - -Acceptance: - -- generated declarations include the hardened listener/context types -- TypeDoc explains context lifecycle behavior -- provider docs do not promote `$eventBusProvider` as the normal user path -- `src/DOCUMENTATION_REQUIREMENT.md` is satisfied - -Verification: - -```bash -make generated-check -make docs-examples-check -make doc -``` - -## Final Readiness Gate - -`$eventBus` and `ng-channel` satisfy the level-9 utility-service contract when: - -- context typing is strict enough to model callback `this` -- scope proxy context auto-owns subscription cleanup -- destroyed scopes do not receive queued events -- `subscribeOnce` follows the same lifecycle rule -- `ng-channel` delegates cleanup to `$eventBus` -- docs teach context hardening instead of manual teardown for scope-owned - subscribers -- `$eventBusProvider` is documented as advanced/provider machinery -- tests cover manual cleanup, scope cleanup, one-time cleanup, queued delivery, - errors, and directive behavior diff --git a/src/services/pubsub/pubsub.spec.ts b/src/services/pubsub/pubsub.spec.ts deleted file mode 100644 index 0bdcb821e..000000000 --- a/src/services/pubsub/pubsub.spec.ts +++ /dev/null @@ -1,464 +0,0 @@ -// @ts-nocheck -/// -import { PubSub } from "./pubsub.ts"; -import { createInjector } from "../../core/di/injector.ts"; -import { Angular } from "../../angular.ts"; -import { wait } from "../../shared/utils.ts"; - -describe("PubSubProvider", () => { - it("should be injectable", () => { - const angular = new Angular(); - - angular.module("test", ["ng"]); - const $injector = createInjector(["test"]); - - expect($injector.has("$eventBus")).toBeTrue(); - expect($injector.get("$eventBus") instanceof PubSub).toBeTrue(); - }); -}); - -describe("PubSub", function () { - let pubsub; - - beforeEach(function () { - pubsub = new PubSub(() => undefined); - }); - - afterEach(function () { - pubsub.dispose(); - pubsub.dispose(); - }); - - it("should create a PubSub instance", function () { - expect(pubsub).not.toBeNull(); - expect(pubsub instanceof PubSub).toBe(true); - }); - - it("should provide injecables", function () { - expect(pubsub._exceptionHandler).not.toBeNull(); - }); - - it("should dispose of the PubSub instance", function () { - expect(pubsub.isDisposed()).toBe(false); - pubsub.dispose(); - expect(pubsub.isDisposed()).toBe(true); - }); - - it("should subscribe and unsubscribe correctly", function () { - function foo1() {} - function bar1() {} - function foo2() {} - function bar2() {} - - expect(pubsub.getCount("foo")).toBe(0); - expect(pubsub.getCount("bar")).toBe(0); - - pubsub.subscribe("foo", foo1); - expect(pubsub.getCount("foo")).toBe(1); - expect(pubsub.getCount("bar")).toBe(0); - - pubsub.subscribe("bar", bar1); - expect(pubsub.getCount("foo")).toBe(1); - expect(pubsub.getCount("bar")).toBe(1); - - pubsub.subscribe("foo", foo2); - expect(pubsub.getCount("foo")).toBe(2); - expect(pubsub.getCount("bar")).toBe(1); - - pubsub.subscribe("bar", bar2); - expect(pubsub.getCount("foo")).toBe(2); - expect(pubsub.getCount("bar")).toBe(2); - - expect(pubsub.unsubscribe("foo", foo1)).toBe(true); - expect(pubsub.getCount("foo")).toBe(1); - expect(pubsub.getCount("bar")).toBe(2); - - expect(pubsub.unsubscribe("foo", foo2)).toBe(true); - expect(pubsub.getCount("foo")).toBe(0); - expect(pubsub.getCount("bar")).toBe(2); - - expect(pubsub.unsubscribe("bar", bar1)).toBe(true); - expect(pubsub.getCount("foo")).toBe(0); - expect(pubsub.getCount("bar")).toBe(1); - - expect(pubsub.unsubscribe("bar", bar2)).toBe(true); - expect(pubsub.getCount("foo")).toBe(0); - expect(pubsub.getCount("bar")).toBe(0); - - expect(pubsub.unsubscribe("baz", foo1)).toBe(false); - expect( - pubsub.unsubscribe("foo", () => { - /* empty */ - }), - ).toBe(false); - }); - - it("should subscribe and unsubscribe with context correctly", function () { - function foo() {} - function bar() {} - - const contextA = {}; - - const contextB = {}; - - expect(pubsub.getCount("X")).toBe(0); - - pubsub.subscribe("X", foo, contextA); - expect(pubsub.getCount("X")).toBe(1); - - pubsub.subscribe("X", bar); - expect(pubsub.getCount("X")).toBe(2); - - pubsub.subscribe("X", bar, contextB); - expect(pubsub.getCount("X")).toBe(3); - - expect(pubsub.unsubscribe("X", foo, contextB)).toBe(false); - - expect(pubsub.unsubscribe("X", foo, contextA)).toBe(true); - expect(pubsub.getCount("X")).toBe(2); - - expect(pubsub.unsubscribe("X", bar)).toBe(true); - expect(pubsub.getCount("X")).toBe(1); - - expect(pubsub.unsubscribe("X", bar, contextB)).toBe(true); - expect(pubsub.getCount("X")).toBe(0); - }); - - it("should subscribe once correctly", async function () { - let called; - - let context; - - called = false; - pubsub.subscribeOnce("someTopic", () => { - called = true; - }); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(1); - expect(called).toBe(false); - - pubsub.publish("someTopic"); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(0); - expect(called).toBe(true); - - context = { called: false }; - pubsub.subscribeOnce( - "someTopic", - function () { - this.called = true; - }, - context, - ); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(1); - expect(context.called).toBe(false); - - pubsub.publish("someTopic"); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(0); - expect(context.called).toBe(true); - - context = { called: false, value: 0 }; - pubsub.subscribeOnce( - "someTopic", - function (value) { - this.called = true; - this.value = value; - }, - context, - ); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(1); - expect(context.called).toBe(false); - expect(context.value).toBe(0); - - pubsub.publish("someTopic", 17); - await wait(); - expect(pubsub.getCount("someTopic")).toBe(0); - expect(context.called).toBe(true); - expect(context.value).toBe(17); - }); - - it("should async subscribe once correctly", function (done) { - let callCount = 0; - - pubsub.subscribeOnce("someTopic", () => { - callCount++; - }); - expect(pubsub.getCount("someTopic")).toBe(1); - pubsub.publish("someTopic"); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(0); - expect(callCount).toBe(1); - done(); - }, 0); - }); - - it("should async subscribe once with context correctly", function (done) { - const context = { callCount: 0 }; - - pubsub.subscribeOnce( - "someTopic", - function () { - this.callCount++; - }, - context, - ); - expect(pubsub.getCount("someTopic")).toBe(1); - - pubsub.publish("someTopic"); - pubsub.publish("someTopic"); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(0); - expect(context.callCount).toBe(1); - done(); - }, 0); - }); - - it("should async subscribe once with context and value correctly", function (done) { - const context = { callCount: 0, value: 0 }; - - pubsub.subscribeOnce( - "someTopic", - function (value) { - this.callCount++; - this.value = value; - }, - context, - ); - expect(pubsub.getCount("someTopic")).toBe(1); - - pubsub.publish("someTopic", 17); - pubsub.publish("someTopic", 42); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(0); - expect(context.callCount).toBe(1); - expect(context.value).toBe(17); - done(); - }, 0); - }); - - it("should subscribe once with bound function correctly", async function () { - const context = { called: false, value: 0 }; - - function subscriber(value) { - this.called = true; - this.value = value; - } - - pubsub.subscribeOnce("someTopic", subscriber.bind(context)); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(context.called).toBe(false); - expect(context.value).toBe(0); - - pubsub.publish("someTopic", 17); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(0); - expect(context.called).toBe(true); - expect(context.value).toBe(17); - }); - - it("should subscribe once with partial function correctly", async function () { - let called = false; - - let value = 0; - - function subscriber(hasBeenCalled, newValue) { - called = hasBeenCalled; - value = newValue; - } - - pubsub.subscribeOnce("someTopic", subscriber.bind(null, true)); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(called).toBe(false); - expect(value).toBe(0); - - pubsub.publish("someTopic", 17); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(0); - expect(called).toBe(true); - expect(value).toBe(17); - }); - - it("should handle self resubscribe correctly", async function () { - let value = null; - - function resubscribe(iteration, newValue) { - pubsub.subscribeOnce("someTopic", resubscribe.bind(null, iteration + 1)); - value = `${newValue}:${iteration}`; - } - - pubsub.subscribeOnce("someTopic", resubscribe.bind(null, 0)); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBeNull(); - - pubsub.publish("someTopic", "foo"); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("foo:0"); - - pubsub.publish("someTopic", "bar"); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("bar:1"); - - pubsub.publish("someTopic", "baz"); - await wait(); - - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("baz:2"); - }); - - it("should handle async self resubscribe correctly", function (done) { - let value = null; - - function resubscribe(iteration, newValue) { - pubsub.subscribeOnce("someTopic", resubscribe.bind(null, iteration + 1)); - value = `${newValue}:${iteration}`; - } - - pubsub.subscribeOnce("someTopic", resubscribe.bind(null, 0)); - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBeNull(); - - pubsub.publish("someTopic", "foo"); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("foo:0"); - - pubsub.publish("someTopic", "bar"); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("bar:1"); - - pubsub.publish("someTopic", "baz"); - - setTimeout(() => { - expect(pubsub.getCount("someTopic")).toBe(1); - expect(value).toBe("baz:2"); - done(); - }, 0); - }, 0); - }, 0); - }); - - describe("publish", () => { - let context, fooCalled, barCalled, SOME_TOPIC; - - beforeEach(function () { - context = {}; - fooCalled = false; - barCalled = false; - SOME_TOPIC = "someTopic"; - }); - - function foo(record) { - fooCalled = true; - expect(record.x).toBe("x"); - expect(record.y).toBe("y"); - } - - function bar(record) { - barCalled = true; - expect(this).toBe(context); - expect(record.x).toBe("x"); - expect(record.y).toBe("y"); - } - - it("should call subscribed functions on publish", async function () { - pubsub.subscribe(SOME_TOPIC, foo); - pubsub.subscribe(SOME_TOPIC, bar, context); - - expect(pubsub.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); - await wait(); - expect(fooCalled).toBe(true, "foo() must have been called"); - expect(barCalled).toBe(true, "bar() must have been called"); - }); - - it("should not call unsubscribed functions on publish", async function () { - pubsub.subscribe(SOME_TOPIC, foo); - pubsub.subscribe(SOME_TOPIC, bar, context); - - pubsub.publish(SOME_TOPIC, { x: "x", y: "y" }); - await wait(); - expect(fooCalled).toBe(true, "foo() must have been called"); - expect(barCalled).toBe(true, "bar() must have been called"); - fooCalled = false; - barCalled = false; - expect(pubsub.unsubscribe(SOME_TOPIC, foo)).toBe(true); - expect(pubsub.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); - - await wait(); - expect(fooCalled).toBe(false, "foo() must not have been called"); - expect(barCalled).toBe(true, "bar() must have been called"); - }); - - it("should only call functions subscribed to the correct topic", async function () { - pubsub.subscribe(SOME_TOPIC, bar, context); - pubsub.subscribe("differentTopic", foo); - - pubsub.publish(SOME_TOPIC, { x: "x", y: "y" }); - fooCalled = false; - barCalled = false; - - await wait(); - expect(pubsub.publish(SOME_TOPIC, { x: "x", y: "y" })).toBe(true); - expect(fooCalled).toBe(false, "foo() must not have been called"); - expect(barCalled).toBe(true, "bar() must have been called"); - }); - - it("should trigger functions if not arguments are provided", async function () { - let called = false; - - pubsub.subscribe(SOME_TOPIC, () => { - called = true; - 0; - }); - - pubsub.publish(SOME_TOPIC); - await wait(); - - expect(pubsub.publish(SOME_TOPIC)).toBe(true); - expect(called).toBeTrue(); - }); - - it("should delegate to exception handler if an error is thrown", async function () { - let thrown = false; - - const thrownError = new Error(); - - let receivedErr; - - pubsub = new PubSub((err) => { - thrown = true; - receivedErr = err; - }); - - pubsub.subscribe(SOME_TOPIC, () => { - throw thrownError; - }); - - pubsub.publish(SOME_TOPIC); - await wait(); - - expect(thrown).toBe(true); - expect(receivedErr).toBe(thrownError); - }); - }); -}); diff --git a/src/services/pubsub/pubsub.ts b/src/services/pubsub/pubsub.ts deleted file mode 100644 index e2f04b3ae..000000000 --- a/src/services/pubsub/pubsub.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - _angularProvider, - _exceptionHandlerProvider, -} from "../../injection-tokens.ts"; -import { nullObject } from "../../shared/utils.ts"; - -type PubSubListener = (...args: unknown[]) => unknown; - -interface ListenerEntry { - _fn: PubSubListener; - _context: unknown; -} -let eventBusInstance: PubSub | undefined; - -/** - * Configurable provider for the application-wide {@link PubSub} event bus. - * - * The provider creates the singleton `$eventBus` service and also exposes it on - * the global Angular service for integrations that publish from outside - * dependency injection. - */ -export class PubSubProvider { - static $inject = [_exceptionHandlerProvider, _angularProvider]; - - eventBus: PubSub; - - constructor( - $exceptionHandler: ng.ExceptionHandlerProvider, - angularProvider: ng.AngularServiceProvider, - ) { - this.eventBus = eventBusInstance = - eventBusInstance ?? new PubSub($exceptionHandler.handler); - angularProvider.$get().$eventBus = this.eventBus; - } - - $get = () => this.eventBus; -} - -export class PubSub { - /** @internal */ - private _topics: Partial>; - /** @internal */ - private _disposed: boolean; - /** @internal */ - private readonly _exceptionHandler: ng.ExceptionHandlerService; - - /** - * Create a publish/subscribe event bus. - * - * Applications usually receive the singleton instance by injecting - * `$eventBus` instead of constructing this class directly. - * - * @param $exceptionHandler - Handler invoked when a subscriber throws. - */ - constructor($exceptionHandler: ng.ExceptionHandlerService) { - this._topics = nullObject(); - this._disposed = false; - this._exceptionHandler = $exceptionHandler; - } - - /** - * Reset the bus to its initial state without disposing it. - * - * All topics and listeners are removed, and the instance can be reused. - */ - reset(): void { - this._topics = nullObject(); - this._disposed = false; - } - - /** - * Checks if instance has been disposed. - * @returns True if disposed. - */ - isDisposed(): boolean { - return this._disposed; - } - - /** - * Dispose the instance, removing all topics and listeners. - */ - dispose(): void { - if (this._disposed) return; - this._disposed = true; - this._topics = nullObject(); - } - - /** - * Subscribe a function to a topic. - * - * The returned function removes only this listener registration. - * - * @param topic - The topic to subscribe to. - * @param fn - The callback function to invoke when published. - * @param [context] - Optional `this` context for the callback. - * @returns A function that unsubscribes this listener. - */ - subscribe( - topic: string, - fn: PubSubListener, - context?: unknown, - ): () => boolean { - if (this._disposed) return () => false; - let listeners = this._topics[topic]; - - if (!listeners) this._topics[topic] = listeners = []; - - const entry: ListenerEntry = { _fn: fn, _context: context }; - - listeners.push(entry); - - return () => this.unsubscribe(topic, fn, context); - } - - /** - * Subscribe a function to a topic only once. - * - * Listener is removed before the first invocation. - * - * @param topic - The topic to subscribe to. - * @param fn - The callback function. - * @param [context] - Optional `this` context for the callback. - * @returns A function that unsubscribes this listener. - */ - subscribeOnce( - topic: string, - fn: PubSubListener, - context?: unknown, - ): () => boolean { - if (this._disposed) return () => false; - - let called = false; - - const wrapper = (...args: unknown[]) => { - if (called) return; - called = true; - - unsub(); // unsubscribe before running - Reflect.apply(fn, context, args); - }; - - const unsub = this.subscribe(topic, wrapper); - - return unsub; - } - - /** - * Unsubscribe a specific function from a topic. - * Matches by function reference and optional context. - * @param topic - The topic to unsubscribe from. - * @param fn - The listener function. - * @param [context] - Optional `this` context. - * @returns True if the listener was found and removed. - */ - unsubscribe(topic: string, fn: PubSubListener, context?: unknown): boolean { - if (this._disposed) return false; - - const listeners = this._topics[topic]; - - if (!listeners || listeners.length === 0) return false; - - for (let i = 0; i < listeners.length; i++) { - const l = listeners[i]; - - if (l._fn === fn && l._context === context) { - listeners.splice(i, 1); - - return true; - } - } - - return false; - } - - /** - * Get the number of subscribers for a topic. - * - * @param topic - Topic name to inspect. - * @returns The number of currently registered listeners. - */ - getCount(topic: string): number { - const listeners = this._topics[topic]; - - return listeners ? listeners.length : 0; - } - - /** - * Publish a value to a topic asynchronously. - * - * All listeners are invoked in the order they were added. - * Delivery is scheduled with `queueMicrotask`. - * - * @param topic - The topic to publish. - * @param args - Arguments to pass to listeners. - * @returns True if any listeners exist for this topic. - */ - publish(topic: string, ...args: unknown[]): boolean { - if (this._disposed) return false; - - const listeners = this._topics[topic]; - - if (!listeners || listeners.length === 0) return false; - - // snapshot to prevent modifications during publish from affecting this call - const snapshot = listeners.slice(); - - queueMicrotask(() => { - for (const { _fn: fn, _context: context } of snapshot) { - try { - fn.apply(context, args); - } catch (err) { - this._exceptionHandler(err); - } - } - }); - - return true; - } -} diff --git a/src/services/realtime/README.md b/src/services/realtime/README.md new file mode 100644 index 000000000..c0b15f6ba --- /dev/null +++ b/src/services/realtime/README.md @@ -0,0 +1,276 @@ +# Realtime Services Internals + +The realtime services own long-lived browser push transports for AngularTS apps. +The implementations in `src/services/websocket/websocket.ts`, +`src/services/sse/sse.ts`, and `src/services/webtransport/webtransport.ts` +turn native Web Platform connections into managed app primitives with default +reconnect, heartbeat, parsing, and cleanup behavior. + +This document is the shared contract for realtime browser lifecycle services. +It describes current behavior so tests and docs can gate later hardening steps. + +## Responsibilities + +- Own browser connection lifecycle for WebSocket, Server-Sent Events, and + WebTransport. +- Provide sensible reconnect and heartbeat defaults so application code does not + need hand-written reconnect loops. +- Preserve native transport capabilities where the browser API exposes unique + behavior. +- Make cleanup explicit through managed `close()` methods. +- Keep deterministic fake-backend tests for reconnect, callbacks, message + parsing, and teardown behavior. + +## Public Surface + +- `$websocket(url, config?)`: creates a managed WebSocket; configure + subprotocols through `config.protocols` + connection. +- `$sse(url, config?)`: creates a managed EventSource/SSE connection. +- `$webTransport(url, config?)`: creates a managed WebTransport connection. +- `WebSocketConfig`, `SseConfig`, and `WebTransportConfig`: per-transport + runtime configuration shapes. +- `ConnectionConfig`: shared internal configuration used by WebSocket and SSE. + +The provider types remain legacy/internal construction details. User-facing +examples should use the runtime services, directives, or later +`app.config(...)` realtime config once that shape is accepted. + +Browser-facing examples must demonstrate configured reconnect policy through +service config or directive attributes. They should not teach application-owned +`setTimeout(connect)` loops, duplicate native connection creation, or other +manual retry code around the managed services. + +## Core Model + +WebSocket and SSE share `ConnectionManager`. + +The shared flow is: + +1. A service call merges provider defaults with call-site config. +2. The service creates a native `WebSocket` or `EventSource`. +3. `ConnectionManager` binds open, message, error, close, and heartbeat + callbacks. +4. Message payloads pass through `transformMessage` before user callbacks. +5. Errors, close events, and heartbeat inactivity schedule reconnect attempts + while the connection is not explicitly closed. + +WebTransport uses `ManagedWebTransportConnection` because native WebTransport is +promise and stream based rather than event-handler based. It preserves the +native `transport`, `ready`, and `closed` promises while adding datagram, +stream, reconnect, and protocol-message helpers. + +Important invariants: + +- Explicit `close()` stops future reconnect attempts. +- Runtime services own browser connections; providers own only construction + defaults. +- Native WebSocket, EventSource, and WebTransport behavior remains reachable + where the service exposes the native object or callback event. + +## Lifecycle + +Realtime connections are created immediately when the service function is called. +They remain live until closed by application code, directive cleanup, native +failure exhaustion, or browser page teardown. + +WebSocket and SSE call `connect()` during `ConnectionManager` construction. +Calling `connect()` again closes the current native connection and opens a new +one unless the managed connection has already been closed. + +WebTransport opens during `ManagedWebTransportConnection` construction. Its +`ready` promise resolves after the current native session is ready, and its +`closed` promise settles when the managed connection closes permanently. + +## Lifecycle Contract + +- Construction touches browser APIs immediately. +- `connect()` restarts WebSocket and SSE managed connections. +- `close()` stops reconnect attempts and closes the current native connection. +- WebTransport exposes `close(closeInfo?)` and keeps `transport` pointing at the + current native session. +- Directive integrations must close connections during element/scope cleanup. +- Service-created connections are application-owned and must be closed by the + caller when no longer needed. + +## Reactivity Contract + +- Current realtime services are callback-driven, not scope-reactive service + state containers. +- Directives may bind a connection instance onto scope when they expose `as` + aliases. +- Connection status is not yet a shared reactive model. +- Do not add reactive status until Pilot C accepts AppContext ownership, + scheduler boundaries, and template use cases. + +## Policy Contract + +Current defaults: + +- WebSocket: `retryDelay: 1000`, `maxRetries: Infinity`, + `heartbeatTimeout: 0`, JSON transform fallback to raw text. +- SSE: `retryDelay: 1000`, `maxRetries: Infinity`, + `heartbeatTimeout: 15000`, JSON transform fallback to raw text. +- WebTransport: reconnect is opt-in with `reconnect: true`; retry delay defaults + to `1000`, and max retries default to no limit in the managed connection. + +Call-site config can override retry delay, max retries, heartbeat timeout, +message transforms, lifecycle callbacks, and transport-specific options. + +Future service-owned config should use `app.config(...)` only after the typed +realtime config shape is designed and tested. The accepted initial shape is a +declarative default policy per transport: + +```ts +app.config({ + sse: { defaults: { retryDelay: 3000, maxRetries: 10 } }, + websocket: { defaults: { protocols: ["json"], heartbeatTimeout: 30000 } }, + webTransport: { defaults: { reconnect: true, retryDelay: 500 } }, +}); +``` + +These keys merge into provider defaults during the config phase. Per-connection +call-site config still overrides app defaults. This is intentionally not a +global `ServiceStatus` or public connection manager. + +## Dependency Replacement Contract + +These services replace common app-level boilerplate around: + +- WebSocket reconnect loops. +- SSE reconnect and URL parameter assembly. +- WebTransport ready/closed promise handling. +- JSON message decoding with safe fallback. +- Explicit cleanup of long-lived browser transport objects. + +They do not replace protocol design, authentication, authorization, durable +offline queues, or server-side session recovery. Those concerns compose through +security policy, workflow/supervisor policy, or application protocols. + +## Composition Contract + +- Lower-level: native WebSocket, EventSource, WebTransport, streams, timers, and + `$log`. +- Same layer: `ConnectionManager` remains internal unless a later slice proves a + public need. +- Higher-level: directives, realtime DOM protocol handling, workflow + orchestration, and security policy may build on these services. +- Realtime services must not depend on DOM scopes for their base runtime + lifecycle. + +## Failure Contract + +- Invalid WebTransport URLs throw synchronously before opening a browser + connection. +- WebTransport constructor failures normalize `ready` rejection and `closed` + settlement behavior. +- WebSocket and SSE browser `error` events call `onError` and schedule reconnect + if retries remain. +- Reconnect exhaustion logs through `$log.warn`. +- Callback exceptions are not yet normalized into diagnostics; diagnostics are a + future Pilot C decision. + +## Recovery Contract + +- Reconnect is in-memory and connection-local. +- No realtime service currently persists session state or unsent messages. +- WebTransport provides `onReconnect` so applications can renegotiate session + state after a replacement native session is ready. +- Workflow supervisor or app protocols should own durable recovery if a + connection needs replay, resume tokens, or idempotent command handling. + +## Scheduling And Ordering + +- Browser open/message/error/close events drive WebSocket and SSE callback order. +- Heartbeat timers reset on open and message. +- Heartbeat timeout closes the current native WebSocket/EventSource and opens a + replacement connection through the same bounded reconnect policy used by + native error and close events. +- Reconnect attempts are delayed by `retryDelay`. +- WebTransport datagram and stream reads are asynchronous and follow native + stream ordering. + +## Native Interop + +- WebSocket and SSE callbacks receive the native event. +- WebSocket `send(data)` JSON-serializes application values. +- SSE uses native `EventSource` and supports `withCredentials`; custom headers + are documented as config-only because EventSource does not natively support + them. +- WebTransport exposes the current native `transport` and native stream methods + where callers need browser-specific behavior. + +## Test Harness + +- `src/services/websocket/websocket.spec.ts` uses a mocked `window.WebSocket`. +- `src/services/sse/sse.spec.ts` uses browser/EventSource fixtures and local + mocks for error, custom event, URL, and heartbeat behavior. +- `src/services/webtransport/webtransport.spec.ts` uses browser WebTransport + integration and constructor failure fakes. +- `src/services/webtransport/webtransport.test.ts` skips the demo path when the + WebTransport backend metadata endpoint is unavailable. + +## Integration Points + +- `src/services/connection/connection-manager.ts`: internal shared lifecycle + manager for WebSocket and SSE. +- `src/directive/realtime/protocol.ts`: realtime DOM protocol message shape. +- `src/directive/websocket`, `src/directive/sse`, and + `src/directive/webtransport`: template-level connection ownership. +- `src/services/security`: future source of auth/header/cookie policy used by + realtime service config or app protocols. + +## Edge Cases + +- `heartbeatTimeout: 0` disables heartbeat checks. +- `maxRetries: Infinity` keeps retrying until explicit close. +- Calling `connect()` after `close()` on WebSocket/SSE is a no-op. +- SSE query parameter objects are JSON-serialized, while null and undefined + become empty query values. +- WebTransport explicit close must not trigger reconnect. + +## Destruction And Cleanup + +- Service callers own cleanup for service-created connections. +- Directives own cleanup for directive-created connections. +- `close()` clears heartbeat/reconnect timers where available and closes the + current native transport. +- Future reactive status must be owned by AppContext or root-specific directive + scope, never by leaked DOM state. + +## Types And Interfaces + +`ConnectionConfig` +: Internal shared WebSocket/SSE lifecycle config. + +`WebSocketConfig` +: WebSocket config, including protocols and realtime protocol callbacks. + +`SseConfig` +: SSE config, including credentials, params, event types, and callbacks. + +`WebTransportConfig` +: WebTransport config, including native options, datagram transforms, +reconnect policy, and renegotiation hooks. + +`WebSocketConnection` +: Managed WebSocket connection with `connect`, `send`, and `close`. + +`SseConnection` +: Managed SSE connection with `connect` and `close`. + +`WebTransportConnection` +: Managed WebTransport connection with native `transport`, `ready`, `closed`, +datagram methods, stream methods, and `close`. + +## Testing Notes + +- Run `make check` and `make lint` before focused realtime tests. +- Focused tests: + - `npx playwright test src/services/websocket/websocket.test.ts` + - `npx playwright test src/services/sse/sse.test.ts` + - `npx playwright test src/services/webtransport/webtransport.test.ts` +- Add deterministic fake-backend tests before changing reconnect or heartbeat + behavior. +- Add type tests before accepting any `app.config({ $sse, $websocket, +$webTransport })` config shape. diff --git a/src/services/rest/rest-crud-demo.html b/src/services/rest/rest-crud-demo.html index d9c79d93d..35abc174f 100644 --- a/src/services/rest/rest-crud-demo.html +++ b/src/services/rest/rest-crud-demo.html @@ -162,25 +162,21 @@ } async remove(item) { - const removed = await this.tasks.delete(item.id); + await this.tasks.delete(item.id); - if (removed && this.scope.selected?.id === item.id) { + if (this.scope.selected?.id === item.id) { this.clear(); } - if (removed) { - const index = this.scope.taskList.findIndex( - (entry) => entry.id === item.id, - ); + const index = this.scope.taskList.findIndex( + (entry) => entry.id === item.id, + ); - if (index >= 0) { - this.scope.taskList.splice(index, 1); - } + if (index >= 0) { + this.scope.taskList.splice(index, 1); } - this.scope.notice = removed - ? `Deleted task #${item.id}` - : "Delete failed"; + this.scope.notice = `Deleted task #${item.id}`; } } diff --git a/src/services/rest/rest.spec.ts b/src/services/rest/rest.spec.ts index afc6d4391..473257320 100644 --- a/src/services/rest/rest.spec.ts +++ b/src/services/rest/rest.spec.ts @@ -1,11 +1,10 @@ // @ts-nocheck /// -import { _http } from "../../injection-tokens.ts"; import { CachedRestBackend, createRestCacheKey, + createRestFactory, HttpRestBackend, - RestProvider, RestService, } from "./rest.ts"; import { expandExpression, expandUriTemplate, pctEncode } from "./rfc.ts"; @@ -80,15 +79,6 @@ describe("$rest", () => { ); }); - it("builds URLs from RFC templates", () => { - const service = restService($http, "/users"); - - expect(service.buildUrl("/users/{id}{?q}", { id: 10, q: "a b" })).toBe( - "/users/10?q=a%20b", - ); - expect(service.buildUrl("/users/{id}", null)).toBe("/users/"); - }); - it("lists entities, maps them, and forwards request options", async () => { $http.and.resolveTo(httpResponse([{ id: 1, name: "Ada" }])); const service = restService($http, "/users{?page}", UserEntity, { @@ -194,7 +184,7 @@ describe("$rest", () => { await expectAsync(service.create({ name: "Raw" })).toBeResolvedTo(raw); }); - it("updates entities and swallows request failures as null", async () => { + it("updates entities and rejects request failures", async () => { $http.and.returnValues( Promise.resolve(httpResponse({ id: 3, name: "Updated" })), Promise.reject(new Error("write failed")), @@ -203,11 +193,11 @@ describe("$rest", () => { const updated = await service.update(3, { name: "Updated" }); - const failed = await service.update(4, { name: "Nope" }); - expect(updated instanceof UserEntity).toBeTrue(); expect(updated.name).toBe("Updated"); - expect(failed).toBeNull(); + await expectAsync( + service.update(4, { name: "Nope" }), + ).toBeRejectedWithError("write failed"); }); it("returns null when update succeeds with a nullish body", async () => { @@ -219,7 +209,7 @@ describe("$rest", () => { ); }); - it("deletes entities and converts failures to false", async () => { + it("deletes entities and rejects request failures", async () => { $http.and.returnValues( Promise.resolve( httpResponse(null, { status: 204, statusText: "No Content" }), @@ -228,8 +218,10 @@ describe("$rest", () => { ); const service = restService($http, "/users"); - await expectAsync(service.delete(1)).toBeResolvedTo(true); - await expectAsync(service.delete(2)).toBeResolvedTo(false); + await expectAsync(service.delete(1)).toBeResolvedTo(undefined); + await expectAsync(service.delete(2)).toBeRejectedWithError( + "delete failed", + ); }); it("sends normalized REST request metadata to custom backends", async () => { @@ -261,7 +253,7 @@ describe("$rest", () => { await expectAsync(service.update(4, { name: "Updated" })).toBeResolvedTo( jasmine.objectContaining({ id: 4, mapped: true }), ); - await expectAsync(service.delete(5)).toBeResolvedTo(true); + await expectAsync(service.delete(5)).toBeResolvedTo(undefined); expect(backend.request.calls.allArgs()).toEqual([ [ @@ -437,6 +429,124 @@ describe("$rest", () => { ); }); + it("evaluates cache policy with normalized request context", async () => { + const cache = new TestRestCacheStore(); + + const network = { + request: jasmine + .createSpy("network") + .and.resolveTo(httpResponse({ id: 7 })), + }; + + const policy = jasmine + .createSpy("cachePolicy") + .and.resolveTo("network-first"); + + const backend = new CachedRestBackend({ + network, + cache, + policy, + }); + + const restRequest = request({ + params: { page: 1 }, + options: { audit: true }, + }); + + await backend.request(restRequest); + + expect(policy).toHaveBeenCalledOnceWith({ + operation: "rest.cache", + method: "GET", + url: "/users/7", + collectionUrl: "/users", + id: undefined, + params: { page: 1 }, + options: { audit: true }, + cacheKey: createRestCacheKey(restRequest), + }); + }); + + it("uses cache policy decisions instead of the static default strategy", async () => { + const cache = new TestRestCacheStore(); + + const network = { request: jasmine.createSpy("network") }; + + const backend = new CachedRestBackend({ + network, + cache, + strategy: "network-first", + policy: () => Promise.resolve("cache-first"), + }); + + const restRequest = request(); + + await cache.set(createRestCacheKey(restRequest), httpResponse({ id: 7 })); + + await expectAsync(backend.request(restRequest)).toBeResolvedTo( + jasmine.objectContaining({ + data: { id: 7 }, + source: "cache", + }), + ); + expect(network.request).not.toHaveBeenCalled(); + }); + + it("accepts structured cache policy decisions", async () => { + const cache = new TestRestCacheStore(); + const backend = new CachedRestBackend({ + network: { request: jasmine.createSpy("network") }, + cache, + policy: () => Promise.resolve({ type: "cache-first" }), + }); + const restRequest = request(); + + await cache.set(createRestCacheKey(restRequest), httpResponse({ id: 7 })); + + await expectAsync(backend.request(restRequest)).toBeResolvedTo( + jasmine.objectContaining({ source: "cache" }), + ); + }); + + it("rejects unsupported cache policy decisions", async () => { + const backend = new CachedRestBackend({ + network: { request: jasmine.createSpy("network") }, + cache: new TestRestCacheStore(), + policy: () => Promise.resolve("unsupported"), + }); + + await expectAsync(backend.request(request())).toBeRejectedWithError( + "Unsupported REST cache strategy: unsupported", + ); + }); + + it("defaults cached backends to network-first reads", async () => { + const cache = new TestRestCacheStore(); + + const network = { + request: jasmine + .createSpy("network") + .and.resolveTo(httpResponse({ id: 8 })), + }; + + const backend = new CachedRestBackend({ + network, + cache, + }); + + const restRequest = request(); + + await cache.set(createRestCacheKey(restRequest), httpResponse({ id: 7 })); + + await expectAsync(backend.request(restRequest)).toBeResolvedTo( + jasmine.objectContaining({ + data: { id: 8 }, + source: "network", + }), + ); + expect(network.request).toHaveBeenCalledOnceWith(restRequest); + }); + it("fetches and caches cache-first misses", async () => { const cache = new TestRestCacheStore(); @@ -777,17 +887,10 @@ describe("$rest", () => { }); }); - describe("RestProvider", () => { - it("exposes the expected injection token and factory", async () => { - const provider = new RestProvider(); - + describe("createRestFactory", () => { + it("creates typed REST services", async () => { const $http = jasmine.createSpy("$http").and.resolveTo(httpResponse([])); - - provider.rest("users", "/users{?page}", UserEntity, { cache: true }); - - expect(provider.$get[0]).toBe(_http); - - const factory = provider.$get[1]($http); + const factory = createRestFactory($http); const service = factory("/admins", UserEntity, { timeout: 10 }); @@ -796,16 +899,12 @@ describe("$rest", () => { await expectAsync(service.list({ page: 1 })).toBeResolvedTo([]); }); - it("supports provider and factory defaults when entityClass and options are omitted", async () => { - const provider = new RestProvider(); - + it("supports factory defaults when entityClass and options are omitted", async () => { const raw = { id: 5, title: "Post" }; const $http = jasmine.createSpy("$http").and.resolveTo(httpResponse(raw)); - provider.rest("posts", "/posts"); - - const factory = provider.$get[1]($http); + const factory = createRestFactory($http); const service = factory("/posts"); @@ -813,15 +912,13 @@ describe("$rest", () => { }); it("uses an options backend instead of the default HTTP backend", async () => { - const provider = new RestProvider(); - const $http = jasmine.createSpy("$http"); const backend = { request: jasmine.createSpy("backend").and.resolveTo(httpResponse([])), }; - const factory = provider.$get[1]($http); + const factory = createRestFactory($http); const service = factory("/admins", UserEntity, { backend, @@ -838,6 +935,25 @@ describe("$rest", () => { ); expect($http).not.toHaveBeenCalled(); }); + + it("merges runtime defaults with resource options", async () => { + const backend = { + request: jasmine.createSpy("backend").and.resolveTo(httpResponse([])), + }; + const factory = createRestFactory(jasmine.createSpy("$http"), { + backend, + withCredentials: true, + timeout: 100, + }); + + await factory("/admins", undefined, { timeout: 10 }).list(); + + expect(backend.request).toHaveBeenCalledWith( + jasmine.objectContaining({ + options: { withCredentials: true, timeout: 10 }, + }), + ); + }); }); }); diff --git a/src/services/rest/rest.ts b/src/services/rest/rest.ts index c95467722..836f9dbdb 100644 --- a/src/services/rest/rest.ts +++ b/src/services/rest/rest.ts @@ -1,4 +1,3 @@ -import { _http } from "../../injection-tokens.ts"; import { isArray, isDefined, @@ -8,21 +7,14 @@ import { import { expandUriTemplate } from "./rfc.ts"; import { HttpRestBackend } from "./http-rest-backend.ts"; import type { HttpMethod, HttpResponse, HttpService } from "../http/http.ts"; +import type { + Policy, + PolicyContext, + PolicyDecision, +} from "../../core/policy/policy.ts"; export { HttpRestBackend } from "./http-rest-backend.ts"; -/** Resource definition registered with {@link RestProvider.rest}. */ -export interface RestDefinition { - /** Informational name for the resource definition. */ - name: string; - /** Base URL or RFC 6570 URI template for the resource. */ - url: string; - /** Constructor for mapping JSON objects to entity instances. */ - entityClass?: EntityClass; - /** Extra REST options merged into each request for this resource. */ - options?: RestOptions; -} - /** Constructor type for mapping JSON objects to entity instances. */ /** * Creates a new entity instance from raw response data. @@ -100,6 +92,22 @@ export type RestCacheStrategy = | "network-first" | "stale-while-revalidate"; +export interface RestCachePolicyContext extends PolicyContext { + operation: "rest.cache"; + method: HttpMethod; + url: string; + collectionUrl?: string; + id?: unknown; + params?: Record; + options?: Record; + cacheKey: string; +} + +export type RestCachePolicy = Policy< + RestCachePolicyContext, + PolicyDecision +>; + /** * Async cache store used by {@link CachedRestBackend}. * @@ -153,8 +161,10 @@ export interface CachedRestBackendOptions { network: RestBackend; /** Async cache store, such as IndexedDB, Cache API, or memory. */ cache: RestCacheStore; - /** Read strategy used for cacheable GET requests. */ - strategy: RestCacheStrategy; + /** Default read strategy used for cacheable GET requests. */ + strategy?: RestCacheStrategy; + /** Runtime policy used to choose the read strategy for each cacheable request. */ + policy?: RestCachePolicy; /** Notified after a stale-while-revalidate refresh succeeds. */ onRevalidate?: (event: RestRevalidateEvent) => void; } @@ -165,6 +175,12 @@ export interface RestOptions extends Record { backend?: RestBackend; } +/** Service configuration for app-level REST defaults. */ +export interface RestConfig { + /** Default options applied to every `$rest(...)` service created by this app. */ + defaults?: RestOptions; +} + /** * Create a deterministic cache key for a REST request. * @@ -196,17 +212,25 @@ function stableSerialize(value: unknown): string { return value === undefined ? "undefined" : JSON.stringify(value); } +const DEFAULT_REST_CACHE_STRATEGY: RestCacheStrategy = "network-first"; + +function createStaticRestCachePolicy( + strategy: RestCacheStrategy, +): RestCachePolicy { + return () => strategy; +} + /** * Composes a network backend with an async cache store. * - * `GET` requests use the configured {@link RestCacheStrategy}. Write requests + * `GET` requests use the configured {@link RestCachePolicy}. Write requests * are sent to the network backend first, then matching cached collection and * entity entries are invalidated after successful writes. */ export class CachedRestBackend implements RestBackend { private readonly _network: RestBackend; private readonly _cache: RestCacheStore; - private readonly _strategy: RestCacheStrategy; + private readonly _policy: RestCachePolicy; private readonly _onRevalidate?: (event: RestRevalidateEvent) => void; /** @@ -216,7 +240,11 @@ export class CachedRestBackend implements RestBackend { constructor(options: CachedRestBackendOptions) { this._network = options.network; this._cache = options.cache; - this._strategy = options.strategy; + this._policy = + options.policy ?? + createStaticRestCachePolicy( + options.strategy ?? DEFAULT_REST_CACHE_STRATEGY, + ); this._onRevalidate = options.onRevalidate; } @@ -236,41 +264,53 @@ export class CachedRestBackend implements RestBackend { return response; } - switch (this._strategy) { + const cacheKey = createRestCacheKey(request); + const decision = await this._policy({ + operation: "rest.cache", + method: request.method, + url: request.url, + collectionUrl: request.collectionUrl, + id: request.id, + params: request.params, + options: request.options, + cacheKey, + }); + + const strategy = typeof decision === "string" ? decision : decision.type; + + switch (strategy) { case "cache-first": - return this._cacheFirst(request); + return this._cacheFirst(request, cacheKey); case "network-first": - return this._networkFirst(request); + return this._networkFirst(request, cacheKey); case "stale-while-revalidate": - return this._staleWhileRevalidate(request); + return this._staleWhileRevalidate(request, cacheKey); } - throw new Error( - `Unsupported REST cache strategy: ${String(this._strategy)}`, - ); + throw new Error(`Unsupported REST cache strategy: ${String(strategy)}`); } - private async _cacheFirst(request: RestRequest): Promise> { - const key = createRestCacheKey(request); - - const cached = await this._cache.get(key); + private async _cacheFirst( + request: RestRequest, + cacheKey: string, + ): Promise> { + const cached = await this._cache.get(cacheKey); if (isDefined(cached)) { return { ...cached, source: "cache" }; } - return this._fetchAndCache(request, key); + return this._fetchAndCache(request, cacheKey); } private async _networkFirst( request: RestRequest, + cacheKey: string, ): Promise> { - const key = createRestCacheKey(request); - try { - return await this._fetchAndCache(request, key); + return await this._fetchAndCache(request, cacheKey); } catch (error) { - const cached = await this._cache.get(key); + const cached = await this._cache.get(cacheKey); if (isDefined(cached)) { return { ...cached, source: "cache", stale: true }; @@ -282,15 +322,14 @@ export class CachedRestBackend implements RestBackend { private async _staleWhileRevalidate( request: RestRequest, + cacheKey: string, ): Promise> { - const key = createRestCacheKey(request); - - const cached = await this._cache.get(key); + const cached = await this._cache.get(cacheKey); if (isDefined(cached)) { - void this._fetchAndCache(request, key).then( + void this._fetchAndCache(request, cacheKey).then( (response) => { - this._onRevalidate?.({ key, request, response }); + this._onRevalidate?.({ key: cacheKey, request, response }); return undefined; }, @@ -300,7 +339,7 @@ export class CachedRestBackend implements RestBackend { return { ...cached, source: "cache", stale: true }; } - return this._fetchAndCache(request, key); + return this._fetchAndCache(request, cacheKey); } private async _fetchAndCache( @@ -365,22 +404,11 @@ export class RestService { this._options = options; } - /** - * Expand an RFC 6570 URI template with the provided parameters. - * - * @param template - URI template such as `/api/{org}/repos/{repo}`. - * @param params - Values used for URI template expansion. - * @returns The expanded URL. - */ - buildUrl(template: string, params: Record): string { - return expandUriTemplate(template, params); - } - /** @internal */ - private _mapEntity(data: unknown): unknown { - if (!data) return data; + private _mapEntity(data: unknown): T | null { + if (isNullOrUndefined(data)) return null; - return this._entityClass ? new this._entityClass(data) : data; + return this._entityClass ? new this._entityClass(data) : (data as T); } /** @@ -390,13 +418,15 @@ export class RestService { * `$http` as query params. Non-array responses resolve to an empty array. */ async list(params: Record = {}): Promise { - const url = this.buildUrl(this._baseUrl, params); + const url = expandUriTemplate(this._baseUrl, params); const resp = await this._request("GET", url, null, params, url); if (!isArray(resp.data)) return []; - return resp.data.map((data) => this._mapEntity(data) as T); + return resp.data + .map((data) => this._mapEntity(data)) + .filter((data): data is T => data !== null); } /** @@ -407,13 +437,13 @@ export class RestService { * @returns The mapped entity, raw response value, or `null` when empty. * @throws Error when `id` is null or undefined. */ - async get(id: ID, params: Record = {}): Promise { + async get(id: ID, params: Record = {}): Promise { if (isNullOrUndefined(id)) throw new Error(`badarg:id ${String(id)}`); - const url = this.buildUrl(`${this._baseUrl}/${String(id)}`, params); + const url = expandUriTemplate(`${this._baseUrl}/${String(id)}`, params); - const collectionUrl = this.buildUrl(this._baseUrl, params); + const collectionUrl = expandUriTemplate(this._baseUrl, params); - const resp = await this._request( + const resp = await this._request( "GET", url, null, @@ -422,7 +452,7 @@ export class RestService { id, ); - return this._mapEntity(resp.data) ?? null; + return this._mapEntity(resp.data); } /** @@ -432,9 +462,9 @@ export class RestService { * @returns The server representation, mapped through `entityClass` when set. * @throws Error when `item` is null or undefined. */ - async create(item: T): Promise { + async create(item: T): Promise { if (isNullOrUndefined(item)) throw new Error(`badarg:item ${String(item)}`); - const resp = await this._request( + const resp = await this._request( "POST", this._baseUrl, item, @@ -453,44 +483,34 @@ export class RestService { * @returns The updated entity, raw value, or `null` when the request fails. * @throws Error when `id` is null or undefined. */ - async update(id: ID, item: Partial): Promise { + async update(id: ID, item: Partial): Promise { if (isNullOrUndefined(id)) throw new Error(`badarg:id ${String(id)}`); const url = `${this._baseUrl}/${String(id)}`; - try { - const resp = await this._request( - "PUT", - url, - item, - {}, - this._baseUrl, - id, - ); + const resp = await this._request( + "PUT", + url, + item, + {}, + this._baseUrl, + id, + ); - return this._mapEntity(resp.data) ?? null; - } catch { - return null; - } + return this._mapEntity(resp.data); } /** * Delete a resource by ID. * * @param id - Resource identifier appended to the base URL. - * @returns `true` when the request succeeds, otherwise `false`. + * @returns A promise that fulfills when the request succeeds. * @throws Error when `id` is null or undefined. */ - async delete(id: ID): Promise { + async delete(id: ID): Promise { if (isNullOrUndefined(id)) throw new Error(`badarg:id ${String(id)}`); const url = `${this._baseUrl}/${String(id)}`; - try { - await this._request("DELETE", url, null, {}, this._baseUrl, id); - - return true; - } catch { - return false; - } + await this._request("DELETE", url, null, {}, this._baseUrl, id); } /** @internal */ @@ -526,42 +546,20 @@ export type RestFactory = ( options?: RestOptions, ) => RestService; -export class RestProvider { - $get: [string, ($http: HttpService) => RestFactory]; - - constructor() { - this.$get = [ - _http, - ($http: HttpService): RestFactory => { - return (baseUrl, entityClass, options = {}) => { - const { backend, ...requestOptions } = options; - - return new RestService( - backend ?? new HttpRestBackend($http), - baseUrl, - entityClass, - requestOptions, - ); - }; - }, - ]; - } - - /** - * Accept a REST resource definition during provider configuration. - * - * Named injectable resources are registered by {@link NgModule.rest}; the - * provider exposes the runtime `$rest` factory. - */ - rest( - name: string, - url: string, - entityClass?: EntityClass, - options: RestOptions = {}, - ): void { - void name; - void url; - void entityClass; - void options; - } +/** @internal */ +export function createRestFactory( + $http: HttpService, + defaults: RestOptions = {}, +): RestFactory { + return (baseUrl, entityClass, options = {}) => { + const mergedOptions = { ...defaults, ...options }; + const { backend, ...requestOptions } = mergedOptions; + + return new RestService( + backend ?? new HttpRestBackend($http), + baseUrl, + entityClass, + requestOptions, + ); + }; } diff --git a/src/services/sce/sce.spec.ts b/src/services/sce/sce.spec.ts index e3a6bbaf6..49c702eff 100644 --- a/src/services/sce/sce.spec.ts +++ b/src/services/sce/sce.spec.ts @@ -8,7 +8,18 @@ import { wait } from "../../shared/test-utils.ts"; describe("SCE", () => { let $sce, $rootScope; - let sceDelegateProvider; + const sceDelegateConfig = { + trustedResourceUrlList(value) { + window.angular._composition.configRegistry.configure("$sceDelegate", { + trustedResourceUrlList: value, + }); + }, + bannedResourceUrlList(value) { + window.angular._composition.configRegistry.configure("$sceDelegate", { + bannedResourceUrlList: value, + }); + }, + }; let logs = []; @@ -19,18 +30,18 @@ describe("SCE", () => { window.angular = new Angular(); window.angular .module("myModule", ["ng"]) + .config({ + $sce: { enabled: false }, + $exceptionHandler: { + handler: (err) => logs.push(err.message), + }, + }) .decorator("$exceptionHandler", () => { return (exception) => { errorLog.push(exception.message); }; }); - createInjector([ - "myModule", - ($sceProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); - $sceProvider.enabled(false); - }, - ]).invoke((_$sce_) => { + createInjector(["myModule"]).invoke((_$sce_) => { $sce = _$sce_; }); }); @@ -44,15 +55,15 @@ describe("SCE", () => { beforeEach(() => { window.angular = new Angular(); logs = []; - createInjector([ - "ng", - ($sceProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => { + window.angular.module("sceEnabled", ["ng"]).config({ + $sce: { enabled: true }, + $exceptionHandler: { + handler: (err) => { logs.push(err.message); - }; - $sceProvider.enabled(true); + }, }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceEnabled"]).invoke((_$sce_) => { $sce = _$sce_; }); }); @@ -78,6 +89,23 @@ describe("SCE", () => { ); }); + it("should use a registered HTML sanitizer", () => { + const sanitize = jasmine + .createSpy("sanitize") + .and.callFake((value) => `sanitized:${value}`); + + window.angular = new Angular(); + window.angular + .module("sceWithSanitizer", ["ng"]) + .value("$sanitize", sanitize) + .config({ $sce: { enabled: true } }); + + createInjector(["sceWithSanitizer"]).invoke((_$sce_) => { + expect(_$sce_.getTrustedHtml("x")).toBe("sanitized:x"); + }); + expect(sanitize).toHaveBeenCalledOnceWith("x"); + }); + it("should NOT wrap non-string values", () => { $sce.trustAsUrl(123); expect(logs[0]).toMatch(/itype/); @@ -155,6 +183,19 @@ describe("SCE", () => { expect(String($sce.getTrustedHtml(wrappedValue))).toBe(originalValue); expect(wrappedValue.toString()).toBe(originalValue.toString()); + expect(wrappedValue.valueOf()).toBe(originalValue); + }); + + it("should preserve empty URL values", () => { + expect($sce.getTrustedUrl(null)).toBeNull(); + expect($sce.getTrustedUrl(undefined)).toBeUndefined(); + expect($sce.getTrustedUrl("")).toBe(""); + }); + + it("should reject values requested for an unknown context", () => { + $sce.getTrusted("unknown", "value"); + + expect(logs[0]).toMatch(/unsafe/); }); it("should return native trusted values for trusted HTML when available", () => { @@ -172,22 +213,20 @@ describe("SCE", () => { describe("replace $sceDelegate", () => { it("should override the default $sce.trustAs/valueOf/etc.", () => { window.angular = new Angular(); - createInjector([ - "ng", - function ($provide) { - $provide.value("$sceDelegate", { - trustAs(type, value) { - return `wrapped:${value}`; - }, - getTrusted(type, value) { - return `unwrapped:${value}`; - }, - valueOf(value) { - return `valueOf:${value}`; - }, - }); - }, - ]).invoke((_$sce_) => { + window.angular + .module("sceDelegateOverride", ["ng"]) + .value("$sceDelegate", { + trustAs(type, value) { + return `wrapped:${value}`; + }, + getTrusted(type, value) { + return `unwrapped:${value}`; + }, + valueOf(value) { + return `valueOf:${value}`; + }, + }); + createInjector(["sceDelegateOverride"]).invoke((_$sce_) => { $sce = _$sce_; }); expect($sce.valueOf("value")).toBe("valueOf:value"); @@ -195,15 +234,15 @@ describe("SCE", () => { }); describe("$sce.parseAs", () => { - window.angular = new Angular(); beforeEach(function () { logs = []; - createInjector([ - "ng", - function ($exceptionHandlerProvider) { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); + window.angular = new Angular(); + window.angular.module("sceParseAs", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), }, - ]).invoke((_$sce_, _$rootScope_) => { + }); + createInjector(["sceParseAs"]).invoke((_$sce_, _$rootScope_) => { $sce = _$sce_; $rootScope = _$rootScope_; }); @@ -244,13 +283,13 @@ describe("SCE", () => { describe("$sceDelegate resource url policies", () => { beforeEach(() => { logs = []; - createInjector([ - "ng", - ($sceDelegateProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); - sceDelegateProvider = $sceDelegateProvider; + window.angular = new Angular(); + window.angular.module("sceResourcePolicies", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceResourcePolicies"]).invoke((_$sce_) => { $sce = _$sce_; }); }); @@ -260,22 +299,22 @@ describe("SCE", () => { }); it("should reject everything when trusted resource URL list is empty", () => { - sceDelegateProvider.trustedResourceUrlList([]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList([]); + sceDelegateConfig.bannedResourceUrlList([]); $sce.getTrustedResourceUrl("#"); expect(logs[0]).toMatch(/insecurl/); }); it("should match against normalized urls", () => { - sceDelegateProvider.trustedResourceUrlList([/^foo$/]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList([/^foo$/]); + sceDelegateConfig.bannedResourceUrlList([]); $sce.getTrustedResourceUrl("foo"); expect(logs[0]).toMatch(/insecurl/); }); it("should not accept unknown matcher type", () => { expect(() => { - sceDelegateProvider.trustedResourceUrlList([{}]); + sceDelegateConfig.trustedResourceUrlList([{}]); }).toThrowError(/imatcher/); }); @@ -299,22 +338,22 @@ describe("SCE", () => { describe("regex matcher", () => { beforeEach(() => { - createInjector([ - "ng", - ($sceDelegateProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); - sceDelegateProvider = $sceDelegateProvider; + window.angular = new Angular(); + window.angular.module("sceRegexMatcher", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceRegexMatcher"]).invoke((_$sce_) => { $sce = _$sce_; }); }); it("should support custom regex", () => { - sceDelegateProvider.trustedResourceUrlList([ + sceDelegateConfig.trustedResourceUrlList([ /^http:\/\/example\.com\/.*/, ]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("http://example.com/foo")).toEqual( "http://example.com/foo", ); @@ -328,10 +367,10 @@ describe("SCE", () => { }); it("should match entire regex", () => { - sceDelegateProvider.trustedResourceUrlList([ + sceDelegateConfig.trustedResourceUrlList([ /https?:\/\/example\.com\/foo/, ]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("http://example.com/foo")).toEqual( "http://example.com/foo", ); @@ -352,20 +391,20 @@ describe("SCE", () => { describe("string matchers", () => { beforeEach(() => { logs = []; - createInjector([ - "ng", - ($sceDelegateProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); - sceDelegateProvider = $sceDelegateProvider; + window.angular = new Angular(); + window.angular.module("sceStringMatchers", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceStringMatchers"]).invoke((_$sce_) => { $sce = _$sce_; }); }); it("should support strings as matchers", () => { - sceDelegateProvider.trustedResourceUrlList(["http://example.com/foo"]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList(["http://example.com/foo"]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("http://example.com/foo")).toEqual( "http://example.com/foo", ); @@ -381,8 +420,8 @@ describe("SCE", () => { }); it("should support the * wildcard", () => { - sceDelegateProvider.trustedResourceUrlList(["http://example.com/foo*"]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList(["http://example.com/foo*"]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("http://example.com/foo")).toEqual( "http://example.com/foo", ); @@ -411,10 +450,8 @@ describe("SCE", () => { }); it("should support the ** wildcard", () => { - sceDelegateProvider.trustedResourceUrlList([ - "http://example.com/foo**", - ]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList(["http://example.com/foo**"]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("http://example.com/foo")).toEqual( "http://example.com/foo", ); @@ -430,7 +467,7 @@ describe("SCE", () => { it("should not accept *** in the string", () => { expect(() => { - sceDelegateProvider.trustedResourceUrlList(["http://***"]); + sceDelegateConfig.trustedResourceUrlList(["http://***"]); }).toThrowError(/iwcard/); }); }); @@ -438,42 +475,43 @@ describe("SCE", () => { describe('"self" matcher', () => { beforeEach(() => { logs = []; - createInjector([ - "ng", - ($sceDelegateProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => logs.push(err.message); - sceDelegateProvider = $sceDelegateProvider; + window.angular = new Angular(); + window.angular.module("sceSelfMatcher", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceSelfMatcher"]).invoke((_$sce_) => { $sce = _$sce_; }); }); it('should support the special string "self" in trusted resource URL list', () => { - sceDelegateProvider.trustedResourceUrlList(["self"]); - sceDelegateProvider.bannedResourceUrlList([]); + sceDelegateConfig.trustedResourceUrlList(["self"]); + sceDelegateConfig.bannedResourceUrlList([]); expect($sce.getTrustedResourceUrl("foo")).toEqual("foo"); }); it('should support the special string "self" in baneed resource URL list', () => { - sceDelegateProvider.trustedResourceUrlList([/.*/]); - sceDelegateProvider.bannedResourceUrlList(["self"]); + sceDelegateConfig.trustedResourceUrlList([/.*/]); + sceDelegateConfig.bannedResourceUrlList(["self"]); $sce.getTrustedResourceUrl("foo"); expect(logs[0]).toMatch(/insecurl/); }); describe("when the document base URL has changed", () => { beforeEach(() => { - createInjector([ - "ng", - ($sceDelegateProvider, $exceptionHandlerProvider) => { - $exceptionHandlerProvider.handler = (err) => - logs.push(err.message); - sceDelegateProvider = $sceDelegateProvider; - sceDelegateProvider.trustedResourceUrlList(["self"]); - sceDelegateProvider.bannedResourceUrlList([]); + window.angular = new Angular(); + window.angular.module("sceChangedBase", ["ng"]).config({ + $exceptionHandler: { + handler: (err) => logs.push(err.message), + }, + $sceDelegate: { + trustedResourceUrlList: ["self"], + bannedResourceUrlList: [], }, - ]).invoke((_$sce_) => { + }); + createInjector(["sceChangedBase"]).invoke((_$sce_) => { $sce = _$sce_; }); }); @@ -510,20 +548,20 @@ describe("SCE", () => { }); it("should have the banned resource URL list override the trusted resource URL list", () => { - sceDelegateProvider.trustedResourceUrlList(["self"]); - sceDelegateProvider.bannedResourceUrlList(["self"]); + sceDelegateConfig.trustedResourceUrlList(["self"]); + sceDelegateConfig.bannedResourceUrlList(["self"]); $sce.getTrustedResourceUrl("foo"); expect(logs[0]).toMatch(/insecurl/); }); it("should support multiple items in both lists", () => { - sceDelegateProvider.trustedResourceUrlList([ + sceDelegateConfig.trustedResourceUrlList([ /^http:\/\/example.com\/1$/, /^http:\/\/example.com\/2$/, /^http:\/\/example.com\/3$/, "self", ]); - sceDelegateProvider.bannedResourceUrlList([ + sceDelegateConfig.bannedResourceUrlList([ /^http:\/\/example.com\/3$/, /.*\/open_redirect/, ]); @@ -586,13 +624,10 @@ describe("SCE", () => { it("should sanitize URL contexts directly", () => { window.angular = new Angular(); - window.angular.module("testSanitizeUri", ["ng"]); - createInjector([ - "testSanitizeUri", - ($sceProvider, $exceptionHandlerProvider) => { - $sceProvider.enabled(true); - }, - ]).invoke((_$sce_) => { + window.angular + .module("testSanitizeUri", ["ng"]) + .config({ $sce: { enabled: true } }); + createInjector(["testSanitizeUri"]).invoke((_$sce_) => { $sce = _$sce_; }); diff --git a/src/services/sce/sce.ts b/src/services/sce/sce.ts index c7b65e487..29ba1831b 100644 --- a/src/services/sce/sce.ts +++ b/src/services/sce/sce.ts @@ -1,10 +1,3 @@ -import { - _exceptionHandler, - _injector, - _parse, - _sceDelegate, - _window, -} from "../../injection-tokens.ts"; import { type ParsedUrl, type ResolvableUrl, @@ -39,6 +32,8 @@ const DEFAULT_A_HREF_SANITIZATION_TRUSTED_URL_LIST = const DEFAULT_IMG_SRC_SANITIZATION_TRUSTED_URL_LIST = /^\s*((https?|ftp|file|blob):|data:image\/)/; +export type SceResourceUrlMatcher = string | RegExp; + type SceMatcher = RegExp | "self"; interface TrustedValueHolder { @@ -101,6 +96,41 @@ export interface SceDelegateService { valueOf(value?: unknown): unknown; } +/** + * Declarative configuration accepted by `NgModule.config({ $sce: ... })`. + */ +export interface SceConfig { + /** + * Whether Strict Contextual Escaping is enabled application-wide. + */ + enabled?: boolean; +} + +/** + * Declarative configuration accepted by + * `NgModule.config({ $sceDelegate: ... })`. + */ +export interface SceDelegateConfig { + /** + * Resource URL matchers trusted by SCE. A provided array replaces the + * provider list. `null` clears the list. + */ + trustedResourceUrlList?: SceResourceUrlMatcher[] | null; + /** + * Resource URL matchers banned by SCE. A provided array replaces the + * provider list. `null` clears the list. + */ + bannedResourceUrlList?: SceResourceUrlMatcher[] | null; + /** + * Regular expression used to trust safe link URLs during sanitization. + */ + aHrefSanitizationTrustedUrlList?: RegExp; + /** + * Regular expression used to trust safe media URLs during sanitization. + */ + imgSrcSanitizationTrustedUrlList?: RegExp; +} + export interface UriSanitizationConfig { /** * Retrieves or overrides the regular expression used to trust safe URLs for @@ -241,28 +271,21 @@ function unwrapTrustedValueForContext( * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things * work because `$sce` delegates to `$sceDelegate` for these operations. * - * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. + * Configure the service with `app.config({ $sceDelegate: ... })`. * * The default instance of `$sceDelegate` should work out of the box with little pain. While you * can override it completely to change the behavior of `$sce`, the common case would - * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting - * your own trusted and banned resource lists for trusting URLs used for loading AngularTS resources - * such as templates. Refer {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and {@link - * ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList} + * involve configuring `$sceDelegate` with typed trusted and banned resource + * lists for URLs used to load AngularTS resources such as templates. */ /** * - * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate - * $sceDelegate service}, used as a delegate for {@link ng.$sce Strict Contextual Escaping (SCE)}. + * Typed `$sceDelegate` configuration controls the service used as a delegate + * for {@link ng.$sce Strict Contextual Escaping (SCE)}. * - * The `$sceDelegateProvider` allows one to get/set the `trustedResourceUrlList` and - * `bannedResourceUrlList` used to ensure that the URLs used for sourcing AngularTS templates and - * other script-running URLs are safe (all places that use the `$sce.RESOURCE_URL` context). See - * {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and - * {@link ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList}, + * `trustedResourceUrlList` and `bannedResourceUrlList` ensure that URLs used + * for AngularTS templates and other script-running resources are safe. * * For the general details about this service in AngularTS, read the main page for {@link ng.$sce * Strict Contextual Escaping (SCE)}. @@ -277,19 +300,20 @@ function unwrapTrustedValueForContext( * Here is what a secure configuration for this scenario might look like: * * ``` - * angular.module('myApp', []).config(function($sceDelegateProvider) { - * $sceDelegateProvider.trustedResourceUrlList([ - * // Allow same origin resource loads. - * 'self', - * // Allow loading from our assets domain. Notice the difference between * and **. - * 'http://srv*.assets.example.com/**' - * ]); - * - * // The banned resource URL list overrides the trusted resource URL list so the open redirect - * // here is blocked. - * $sceDelegateProvider.bannedResourceUrlList([ - * 'http://myapp.example.com/clickThru**' - * ]); + * angular.module('myApp', []).config({ + * $sceDelegate: { + * trustedResourceUrlList: [ + * // Allow same origin resource loads. + * 'self', + * // Allow loading from our assets domain. Notice the difference between * and **. + * 'http://srv*.assets.example.com/**', + * ], + * // The banned resource URL list overrides the trusted resource URL list so the open redirect + * // here is blocked. + * bannedResourceUrlList: [ + * 'http://myapp.example.com/clickThru**', + * ], + * }, * }); * ``` * Note that an empty trusted resource URL list will block every resource URL from being loaded, and will require @@ -300,21 +324,21 @@ function unwrapTrustedValueForContext( * from the trusted resource URL lsit. This helps to mitigate the security impact of certain types * of issues, like for instance attacker-controlled `ng-includes`. */ -export class SceDelegateProvider implements UriSanitizationConfig { - trustedResourceUrlList: (value?: SceMatcher[] | null) => SceMatcher[]; - bannedResourceUrlList: (value?: SceMatcher[] | null) => SceMatcher[]; +/** @internal */ +export class SceDelegateConfiguration implements UriSanitizationConfig { + trustedResourceUrlList: ( + value?: SceResourceUrlMatcher[] | null, + ) => (RegExp | "self")[]; + bannedResourceUrlList: ( + value?: SceResourceUrlMatcher[] | null, + ) => (RegExp | "self")[]; aHrefSanitizationTrustedUrlList: (regexp?: RegExp) => RegExp | this; imgSrcSanitizationTrustedUrlList: (regexp?: RegExp) => RegExp | this; - $get: [ - string, - string, - string, - ( - $injector: ng.InjectorService, - $window: Window, - $exceptionHandler: ng.ExceptionHandlerService, - ) => SceDelegateService, - ]; + createService: ( + $injector: ng.InjectorService, + $window: Window, + $exceptionHandler: ng.ExceptionHandlerService, + ) => SceDelegateService; constructor() { // Resource URLs can also be trusted by policy. @@ -349,7 +373,9 @@ export class SceDelegateProvider implements UriSanitizationConfig { * its origin with other apps! It is a good idea to limit it to only your application's directory. * */ - this.trustedResourceUrlList = function (value?: SceMatcher[] | null) { + this.trustedResourceUrlList = function ( + value?: SceResourceUrlMatcher[] | null, + ) { if (arguments.length) { const list = value ?? []; @@ -381,7 +407,9 @@ export class SceDelegateProvider implements UriSanitizationConfig { * The **default value** when no trusted resource URL list has been explicitly set is the empty * array (i.e. there is no `bannedResourceUrlList`.) */ - this.bannedResourceUrlList = function (value?: SceMatcher[] | null) { + this.bannedResourceUrlList = function ( + value?: SceResourceUrlMatcher[] | null, + ) { if (arguments.length) { const list = value ?? []; @@ -448,376 +476,358 @@ export class SceDelegateProvider implements UriSanitizationConfig { return imgSrcSanitizationTrustedUrlList; }; - this.$get = [ - _injector, - _window, - _exceptionHandler, + /** Creates `$sceDelegate` from the current policy configuration. */ + this.createService = function ( + $injector: ng.InjectorService, + $window: Window, + $exceptionHandler: ng.ExceptionHandlerService, + ) { + const trustedTypesPolicy = getTrustedTypesPolicy($window); + + let htmlSanitizer: (...args: unknown[]) => unknown = function () { + $exceptionHandler( + $sceError( + "unsafe", + "Attempting to use an unsafe value in a safe context.", + ), + ); + }; + + if ($injector.has("$sanitize")) { + htmlSanitizer = + $injector.get<(...args: unknown[]) => unknown>("$sanitize"); + } + /** - * Creates the `$sceDelegate` service using the configured policies and sanitizers. + * Tests whether a parsed URL matches one SCE allow/deny matcher. */ - function ( - $injector: ng.InjectorService, - $window: Window, - $exceptionHandler: ng.ExceptionHandlerService, - ) { - const trustedTypesPolicy = getTrustedTypesPolicy($window); - - let htmlSanitizer: (...args: unknown[]) => unknown = function () { - $exceptionHandler( - $sceError( - "unsafe", - "Attempting to use an unsafe value in a safe context.", - ), + function matchUrl(matcher: SceMatcher, parsedUrl: ParsedUrl): boolean { + if (matcher === "self") { + return ( + urlIsSameOrigin(parsedUrl) || urlIsSameOriginAsBaseUrl(parsedUrl) ); - }; - - if ($injector.has("$sanitize")) { - htmlSanitizer = $injector.get("$sanitize") as ( - ...args: unknown[] - ) => unknown; } - /** - * Tests whether a parsed URL matches one SCE allow/deny matcher. - */ - function matchUrl(matcher: SceMatcher, parsedUrl: ParsedUrl): boolean { - if (matcher === "self") { - return ( - urlIsSameOrigin(parsedUrl) || urlIsSameOriginAsBaseUrl(parsedUrl) - ); - } - - // definitely a regex. See adjustMatchers() - return !!matcher.exec(parsedUrl.href); - } + // definitely a regex. See adjustMatchers() + return !!matcher.exec(parsedUrl.href); + } - /** - * Returns whether a resource URL is permitted by the current policy lists. - */ - function isResourceUrlAllowedByPolicy(url: ResolvableUrl): boolean { - const parsedUrl = urlResolve(url); + /** + * Returns whether a resource URL is permitted by the current policy lists. + */ + function isResourceUrlAllowedByPolicy(url: ResolvableUrl): boolean { + const parsedUrl = urlResolve(url); - let i: number; + let i: number; - let j: number; + let j: number; - let allowed = false; + let allowed = false; - // Ensure that at least one item from the trusted resource URL list allows this url. - for (i = 0, j = trustedResourceUrlList.length; i < j; i++) { - if (matchUrl(trustedResourceUrlList[i], parsedUrl)) { - allowed = true; - break; - } + // Ensure that at least one item from the trusted resource URL list allows this url. + for (i = 0, j = trustedResourceUrlList.length; i < j; i++) { + if (matchUrl(trustedResourceUrlList[i], parsedUrl)) { + allowed = true; + break; } + } - if (allowed) { - // Ensure that no item from the banned resource URL list has blocked this url. - for (i = 0, j = bannedResourceUrlList.length; i < j; i++) { - if (matchUrl(bannedResourceUrlList[i], parsedUrl)) { - allowed = false; - break; - } + if (allowed) { + // Ensure that no item from the banned resource URL list has blocked this url. + for (i = 0, j = bannedResourceUrlList.length; i < j; i++) { + if (matchUrl(bannedResourceUrlList[i], parsedUrl)) { + allowed = false; + break; } } - - return allowed; } - function sanitizeUri( - uri: string | null | undefined, - isMediaUrl?: boolean, - ): string | null | undefined { - if (!uri) { - return uri; - } - - const regex = isMediaUrl - ? imgSrcSanitizationTrustedUrlList - : aHrefSanitizationTrustedUrlList; + return allowed; + } - const normalizedVal = new URL(uri.trim(), $window.location.href).href; + function sanitizeUri(uri: string, isMediaUrl?: boolean): string { + const regex = isMediaUrl + ? imgSrcSanitizationTrustedUrlList + : aHrefSanitizationTrustedUrlList; - if (normalizedVal !== "" && !normalizedVal.match(regex)) { - return `unsafe:${normalizedVal}`; - } + const normalizedVal = new URL(uri.trim(), $window.location.href).href; - return uri; + if (normalizedVal !== "" && !normalizedVal.match(regex)) { + return `unsafe:${normalizedVal}`; } - /** - * Creates one trusted-value holder constructor for a specific SCE context. - */ - function generateHolderType( - Base?: TrustedValueHolderConstructor, - ): TrustedValueHolderConstructor { - /** @param trustedValue */ - const holderType = function TrustedValueHolderType( - this: TrustedValueHolder, - trustedValue = "", - trustedType = trustedValue, - ) { - this._unwrapTrustedValue = function () { - return trustedValue; - }; - this._unwrapTrustedType = function () { - return trustedType; - }; - } as unknown as TrustedValueHolderConstructor; - - if (Base) { - holderType.prototype = new Base(); - } - (holderType.prototype as TrustedValueHolder).valueOf = - function sceValueOf() { - return this._unwrapTrustedValue(); - }; - (holderType.prototype as TrustedValueHolder).toString = - function sceToString() { - return this._unwrapTrustedValue(); - }; - - return holderType; + return uri; + } + + /** + * Creates one trusted-value holder constructor for a specific SCE context. + */ + function generateHolderType( + Base?: TrustedValueHolderConstructor, + ): TrustedValueHolderConstructor { + /** @param trustedValue */ + const holderType = function TrustedValueHolderType( + this: TrustedValueHolder, + trustedValue = "", + trustedType = trustedValue, + ) { + this._unwrapTrustedValue = function () { + return trustedValue; + }; + this._unwrapTrustedType = function () { + return trustedType; + }; + } as unknown as TrustedValueHolderConstructor; + + if (Base) { + holderType.prototype = new Base(); } + (holderType.prototype as TrustedValueHolder).valueOf = + function sceValueOf() { + return this._unwrapTrustedValue(); + }; + (holderType.prototype as TrustedValueHolder).toString = + function sceToString() { + return this._unwrapTrustedValue(); + }; + + return holderType; + } - const trustedValueHolderBase = generateHolderType(); + const trustedValueHolderBase = generateHolderType(); - const byType: Record = {}; + const byType: Record = {}; - byType[SCE_CONTEXTS._HTML] = generateHolderType(trustedValueHolderBase); - byType[SCE_CONTEXTS._MEDIA_URL] = generateHolderType( - trustedValueHolderBase, - ); - byType[SCE_CONTEXTS._URL] = generateHolderType( - byType[SCE_CONTEXTS._MEDIA_URL], - ); - byType[SCE_CONTEXTS._RESOURCE_URL] = generateHolderType( - byType[SCE_CONTEXTS._URL], - ); + byType[SCE_CONTEXTS._HTML] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS._MEDIA_URL] = generateHolderType( + trustedValueHolderBase, + ); + byType[SCE_CONTEXTS._URL] = generateHolderType( + byType[SCE_CONTEXTS._MEDIA_URL], + ); + byType[SCE_CONTEXTS._RESOURCE_URL] = generateHolderType( + byType[SCE_CONTEXTS._URL], + ); - /** - * Returns a trusted representation of the parameter for the specified context. This trusted - * object will later on be used as-is, without any security check, by bindings or directives - * that require this security context. - * For instance, marking a string as trusted for the `$sce.HTML` context will entirely bypass - * the potential `$sanitize` call in corresponding `$sce.HTML` bindings or directives, such as - * `ng-bind-html`. Note that in most cases you won't need to call this function: if you have the - * sanitizer loaded, passing the value itself will render all the HTML that does not pose a - * security risk. - * - * See {@link ng.$sceDelegate#getTrusted getTrusted} for the function that will consume those - * trusted values, and {@link ng.$sce $sce} for general documentation about strict contextual - * escaping. - * - * @param type The context in which this value is safe for use, e.g. `$sce.URL`, - * `$sce.RESOURCE_URL` or `$sce.HTML`. - * - * @param trustedValue The value that should be considered trusted. - * @returns A trusted representation of value, that can be used in the given context. - */ - function trustAs(type: SceContext, trustedValue: unknown): unknown { - const Constructor = - isDefined(type) && hasOwn(byType, type) ? byType[type] : null; - - if (!Constructor) { - $exceptionHandler( - $sceError( - "icontext", - "Attempted to trust a value in invalid context. Context: {0}; Value: {1}", - type, - trustedValue, - ), - ); - - return undefined; - } + /** + * Returns a trusted representation of the parameter for the specified context. This trusted + * object will later on be used as-is, without any security check, by bindings or directives + * that require this security context. + * For instance, marking a string as trusted for the `$sce.HTML` context will entirely bypass + * the potential `$sanitize` call in corresponding `$sce.HTML` bindings or directives, such as + * `ng-bind-html`. Note that in most cases you won't need to call this function: if you have the + * sanitizer loaded, passing the value itself will render all the HTML that does not pose a + * security risk. + * + * See {@link ng.$sceDelegate#getTrusted getTrusted} for the function that will consume those + * trusted values, and {@link ng.$sce $sce} for general documentation about strict contextual + * escaping. + * + * @param type The context in which this value is safe for use, e.g. `$sce.URL`, + * `$sce.RESOURCE_URL` or `$sce.HTML`. + * + * @param trustedValue The value that should be considered trusted. + * @returns A trusted representation of value, that can be used in the given context. + */ + function trustAs(type: SceContext, trustedValue: unknown): unknown { + const Constructor = + isDefined(type) && hasOwn(byType, type) ? byType[type] : null; - if ( - trustedValue === null || - isUndefined(trustedValue) || - trustedValue === "" - ) { - return trustedValue; - } + if (!Constructor) { + $exceptionHandler( + $sceError( + "icontext", + "Attempted to trust a value in invalid context. Context: {0}; Value: {1}", + type, + trustedValue, + ), + ); - // All the current contexts in SCE_CONTEXTS happen to be strings. In order to avoid trusting - // mutable objects, we ensure here that the value passed in is actually a string. - if (!isString(trustedValue)) { - $exceptionHandler( - $sceError( - "itype", - "Attempted to trust a non-string value in a content requiring a string: Context: {0}", - type, - ), - ); - - return undefined; - } + return undefined; + } - const tst = new Constructor( - trustedValue, - createTrustedType(trustedTypesPolicy, type, trustedValue), + if ( + trustedValue === null || + isUndefined(trustedValue) || + trustedValue === "" + ) { + return trustedValue; + } + + // All the current contexts in SCE_CONTEXTS happen to be strings. In order to avoid trusting + // mutable objects, we ensure here that the value passed in is actually a string. + if (!isString(trustedValue)) { + $exceptionHandler( + $sceError( + "itype", + "Attempted to trust a non-string value in a content requiring a string: Context: {0}", + type, + ), ); - return tst; + return undefined; } - /** - * If the passed parameter had been returned by a prior call to {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`}, returns the value that had been passed to {@link - * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. - * - * If the passed parameter is not a value that had been returned by {@link - * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}, it must be returned as-is. - * - * @param maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} - * call or anything else. - * @returns The `value` that was originally provided to {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} if `value` is the result of such a call. Otherwise, returns - * `value` unchanged. - */ - function valueOf(maybeTrusted: unknown): unknown { - if (isInstanceOf(maybeTrusted, trustedValueHolderBase)) { - return maybeTrusted._unwrapTrustedValue(); - } + const tst = new Constructor( + trustedValue, + createTrustedType(trustedTypesPolicy, type, trustedValue), + ); - return maybeTrusted; + return tst; + } + + /** + * If the passed parameter had been returned by a prior call to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`}, returns the value that had been passed to {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. + * + * If the passed parameter is not a value that had been returned by {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}, it must be returned as-is. + * + * @param maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} + * call or anything else. + * @returns The `value` that was originally provided to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} if `value` is the result of such a call. Otherwise, returns + * `value` unchanged. + */ + function valueOf(maybeTrusted: unknown): unknown { + if (isInstanceOf(maybeTrusted, trustedValueHolderBase)) { + return maybeTrusted._unwrapTrustedValue(); } - /** - * - * Given an object and a security context in which to assign it, returns a value that's safe to - * use in this context, which was represented by the parameter. To do so, this function either - * unwraps the safe type it has been given (for instance, a {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} result), or it might try to sanitize the value given, depending on - * the context and sanitizer availablility. - * - * The contexts that can be sanitized are $sce.MEDIA_URL, $sce.URL and $sce.HTML. The first two are available - * by default, and the third one relies on the `$sanitize` service (which may be loaded through - * the `ngSanitize` module). Furthermore, for $sce.RESOURCE_URL context, a plain string may be - * accepted if the resource url policy defined by {@link ng.$sceDelegateProvider#trustedResourceUrlList - * `$sceDelegateProvider.trustedResourceUrlList`} and {@link ng.$sceDelegateProvider#bannedResourceUrlList - * `$sceDelegateProvider.bannedResourceUrlList`} accepts that resource. - * - * This function will throw if the safe type isn't appropriate for this context, or if the - * value given cannot be accepted in the context (which might be caused by sanitization not - * being available, or the value not being recognized as safe). - * - *
- * Disabling auto-escaping is extremely dangerous, it usually creates a Cross Site Scripting - * (XSS) vulnerability in your application. - *
- * - * @param type The context in which this value is to be used (such as `$sce.HTML`). - * @param maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} call, or anything else (which will not be considered trusted.) - * @returns A version of the value that's safe to use in the given context, or throws an - * exception if this is impossible. - */ - function getTrusted(type: SceContext, maybeTrusted: unknown): unknown { - if ( - maybeTrusted === null || - isUndefined(maybeTrusted) || - maybeTrusted === "" - ) { - return maybeTrusted; - } - const constructor = hasOwn(byType, type) ? byType[type] : null; + return maybeTrusted; + } - // If maybeTrusted is a trusted class instance or subclass instance, then unwrap and return - // as-is. - if (constructor && isInstanceOf(maybeTrusted, constructor)) { - return unwrapTrustedValueForContext(type, maybeTrusted); - } + /** + * + * Given an object and a security context in which to assign it, returns a value that's safe to + * use in this context, which was represented by the parameter. To do so, this function either + * unwraps the safe type it has been given (for instance, a {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} result), or it might try to sanitize the value given, depending on + * the context and sanitizer availablility. + * + * The contexts that can be sanitized are $sce.MEDIA_URL, $sce.URL and $sce.HTML. The first two are available + * by default, and the third one relies on the `$sanitize` service (which may be loaded through + * the `ngSanitize` module). Furthermore, for $sce.RESOURCE_URL context, a plain string may be + * accepted if the resource URL policy configured through + * `app.config({ $sceDelegate: ... })` accepts that resource. + * + * This function will throw if the safe type isn't appropriate for this context, or if the + * value given cannot be accepted in the context (which might be caused by sanitization not + * being available, or the value not being recognized as safe). + * + *
+ * Disabling auto-escaping is extremely dangerous, it usually creates a Cross Site Scripting + * (XSS) vulnerability in your application. + *
+ * + * @param type The context in which this value is to be used (such as `$sce.HTML`). + * @param maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} call, or anything else (which will not be considered trusted.) + * @returns A version of the value that's safe to use in the given context, or throws an + * exception if this is impossible. + */ + function getTrusted(type: SceContext, maybeTrusted: unknown): unknown { + if ( + maybeTrusted === null || + isUndefined(maybeTrusted) || + maybeTrusted === "" + ) { + return maybeTrusted; + } + const constructor = hasOwn(byType, type) ? byType[type] : null; - // If maybeTrusted is a trusted class instance but not of the correct trusted type - // then unwrap it and allow it to pass through to the rest of the checks - const unwrapTrustedValue: unknown = isObject(maybeTrusted) - ? (maybeTrusted as { _unwrapTrustedValue?: unknown }) - ._unwrapTrustedValue - : undefined; + // If maybeTrusted is a trusted class instance or subclass instance, then unwrap and return + // as-is. + if (constructor && isInstanceOf(maybeTrusted, constructor)) { + return unwrapTrustedValueForContext(type, maybeTrusted); + } - if (isFunction(unwrapTrustedValue)) { - maybeTrusted = unwrapTrustedValue.call(maybeTrusted); - } + // If maybeTrusted is a trusted class instance but not of the correct trusted type + // then unwrap it and allow it to pass through to the rest of the checks + const unwrapTrustedValue: unknown = isObject(maybeTrusted) + ? (maybeTrusted as { _unwrapTrustedValue?: unknown }) + ._unwrapTrustedValue + : undefined; - // If we get here, then we will either sanitize the value or throw an exception. - if (type === SCE_CONTEXTS._MEDIA_URL || type === SCE_CONTEXTS._URL) { - // we attempt to sanitize non-resource URLs - return sanitizeUri( - String(maybeTrusted), - type === SCE_CONTEXTS._MEDIA_URL, - ); - } + if (isFunction(unwrapTrustedValue)) { + maybeTrusted = unwrapTrustedValue.call(maybeTrusted); + } - if (type === SCE_CONTEXTS._RESOURCE_URL) { - if (isResourceUrlAllowedByPolicy(maybeTrusted as ResolvableUrl)) { - return maybeTrusted; - } - $exceptionHandler( - $sceError( - "insecurl", - "Blocked loading resource from url not allowed by $sceDelegate policy. URL: {0}", - String(maybeTrusted), - ), - ); - - return undefined; + // If we get here, then we will either sanitize the value or throw an exception. + if (type === SCE_CONTEXTS._MEDIA_URL || type === SCE_CONTEXTS._URL) { + // we attempt to sanitize non-resource URLs + return sanitizeUri( + String(maybeTrusted), + type === SCE_CONTEXTS._MEDIA_URL, + ); + } + + if (type === SCE_CONTEXTS._RESOURCE_URL) { + if (isResourceUrlAllowedByPolicy(maybeTrusted as ResolvableUrl)) { + return maybeTrusted; } + $exceptionHandler( + $sceError( + "insecurl", + "Blocked loading resource from url not allowed by $sceDelegate policy. URL: {0}", + String(maybeTrusted), + ), + ); - // htmlSanitizer throws its own error when no sanitizer is available. - return htmlSanitizer(); + return undefined; } - return { trustAs, getTrusted, valueOf }; - }, - ]; + // htmlSanitizer throws its own error when no sanitizer is available. + return htmlSanitizer(maybeTrusted); + } + + return { trustAs, getTrusted, valueOf }; + }; } } -/** Provider configuration surface available as `$sceProvider`. */ -export interface SceProvider { - /** - * Enables or disables SCE application-wide and returns the current state. - */ - enabled(value?: boolean): boolean; - /** @internal */ - $get?: unknown; -} +/** @internal */ +export class SceConfiguration { + enabled: (value?: boolean) => boolean; + createService: ( + $parse: ng.ParseService, + $sceDelegate: ng.SceDelegateService, + ) => SceService; -export function SceProvider(this: SceProvider): void { - let enabled = true; + constructor() { + let enabled = true; - /** - * @param value If provided, then enables/disables SCE application-wide. - * @returns True if SCE is enabled, false otherwise. - * - * - * Enables/disables SCE and returns the current value. - */ - this.enabled = function (value?: boolean) { - if (arguments.length) { - enabled = !!value; - } + /** + * @param value If provided, then enables/disables SCE application-wide. + * @returns True if SCE is enabled, false otherwise. + * + * + * Enables/disables SCE and returns the current value. + */ + this.enabled = function (value?: boolean) { + if (arguments.length) { + enabled = !!value; + } - return enabled; - }; + return enabled; + }; - this.$get = [ - _parse, - _sceDelegate, /** * Creates the runtime `$sce` service. */ - ($parse: ng.ParseService, $sceDelegate: ng.SceDelegateService) => { + this.createService = ( + $parse: ng.ParseService, + $sceDelegate: ng.SceDelegateService, + ) => { const sce = {} as ng.SceService & Record & { parseAs: (type: SceContext, expr: string) => CompiledExpression; }; /** - * @returns True if SCE is enabled, false otherwise. If you want to set the value, you - * have to do it at module config time on {@link ng.$sceProvider $sceProvider}. + * @returns True if SCE is enabled, false otherwise. Configure the value + * through `app.config({ $sce: { enabled } })`. * * * Returns a boolean indicating if SCE is enabled. @@ -1031,6 +1041,6 @@ export function SceProvider(this: SceProvider): void { }); return sce; - }, - ]; + }; + } } diff --git a/src/services/security/README.md b/src/services/security/README.md new file mode 100644 index 000000000..f33a57a39 --- /dev/null +++ b/src/services/security/README.md @@ -0,0 +1,81 @@ +# Security Service + +This module owns the cross-cutting `$security` policy used to gate HTTP +requests and router transitions before sensitive side effects begin. + +## Responsibilities + +- Provide one stable injectable `SecurityPolicy` service per runtime. +- Apply typed `NgModule.config({ $security: ... })` configuration. +- Evaluate request credential branches in declared order. +- Attach JWT, basic-auth, or browser-managed cookie credentials only after an + allow decision. +- Evaluate navigation rules before router resolves, controllers, and views. + +## Public Surface + +- `SecurityPolicy` is the injectable `$security` contract. +- `SecurityPolicyConfig` is the typed module configuration contract. +- `SecurityPolicyDecision` describes allow, deny, and redirect results. +- Request, navigation, credential, and rule types document their corresponding + policy inputs and outputs. + +Runtime configuration records and construction helpers are internal +composition details. Applications do not inject or configure a security +provider. + +## Configuration + +```ts +angular.module("app", []).config({ + $security: { + defaultDecision: "deny", + branches: ["jwt", "cookieSession"], + credentials: { + jwt: () => sessionStorage.getItem("token"), + cookieSession: { withCredentials: true }, + }, + navigation: { + rules: [ + { + state: "admin", + decision: "redirect", + target: "login", + }, + ], + }, + }, +}); +``` + +## Runtime Model + +Runtime composition creates private mutable configuration and one stable policy +object before the router is assembled. Module config blocks update that private +record. `$http`, the router, and injected `$security` all retain the same policy +object and therefore observe the configured behavior without provider access. + +## Policy Contract + +The default decision is `allow`. Credential branches are evaluated in their +declared order. Credentialed HTTP requests over insecure transport are denied +unless `allowInsecureTransport` is explicitly enabled. Navigation rules are +matched before the default decision and may allow, deny, or redirect. + +Authentication state, token storage, permission discovery, and custom identity +protocols remain application-owned. AngularTS coordinates their decisions +across framework consumers. + +## Lifecycle + +The service owns no browser listeners, timers, connections, or durable state. +It is created once with its runtime and becomes unreachable when that runtime is +destroyed. + +## Testing Notes + +- `security.spec.ts` verifies policy ordering, transport gates, credentials, + and navigation decisions. +- HTTP and router specs verify that both consumers use the same service + contract. +- NgModule specs verify typed runtime configuration dispatch. diff --git a/src/services/security/SECURITY_POLICY_MIGRATION.json b/src/services/security/SECURITY_POLICY_MIGRATION.json new file mode 100644 index 000000000..b4289ee57 --- /dev/null +++ b/src/services/security/SECURITY_POLICY_MIGRATION.json @@ -0,0 +1,26 @@ +[ + { + "from": "module.value(\"$security\", { checkRequest, attachRequestAuth })", + "to": "module.config({ $security: { branches: [\"jwt\"], credentials: { jwt } } })", + "reason": "JWT request policy is a built-in cross-service policy branch.", + "status": "preferred" + }, + { + "from": "$http defaults or interceptors that manually attach bearer tokens", + "to": "module.config({ $security: { branches: [\"jwt\"], credentials: { jwt } } })", + "reason": "The policy gate evaluates before request side effects and attaches credentials only after allow decisions.", + "status": "preferred" + }, + { + "from": "$http calls that manually set Cookie headers", + "to": "module.config({ $security: { branches: [\"cookieSession\"], credentials: { cookieSession: true } } })", + "reason": "Cookie sessions should use browser-managed credentialed requests instead of user-authored Cookie headers.", + "status": "preferred" + }, + { + "from": "router hooks that redirect protected routes ad hoc", + "to": "module.config({ $security: { navigation: { rules: [{ state, decision, target }] } } })", + "reason": "Navigation policy is evaluated before controller and view lifecycle work starts.", + "status": "preferred" + } +] diff --git a/src/core/security/security-adapter.html b/src/services/security/security-adapter.html similarity index 90% rename from src/core/security/security-adapter.html rename to src/services/security/security-adapter.html index 63ffd152d..cb99b2430 100644 --- a/src/core/security/security-adapter.html +++ b/src/services/security/security-adapter.html @@ -13,7 +13,7 @@ diff --git a/src/services/security/security-adapter.spec.ts b/src/services/security/security-adapter.spec.ts new file mode 100644 index 000000000..131e204aa --- /dev/null +++ b/src/services/security/security-adapter.spec.ts @@ -0,0 +1,17 @@ +/// +import { passThroughSecurityAdapter } from "./security-adapter.ts"; + +describe("security adapter", () => { + it("provides explicit pass-through behavior for runtimes without SCE", () => { + const adapter = passThroughSecurityAdapter; + + expect(adapter.getTrusted("url", "javascript:test()")).toBe( + "javascript:test()", + ); + expect(adapter.getTrustedMediaUrl("javascript:image()")).toBe( + "javascript:image()", + ); + expect(adapter.valueOf("value")).toBe("value"); + expect(adapter.valueOf(undefined)).toBeUndefined(); + }); +}); diff --git a/src/core/security/security-adapter.test.ts b/src/services/security/security-adapter.test.ts similarity index 74% rename from src/core/security/security-adapter.test.ts rename to src/services/security/security-adapter.test.ts index 529fbb4ad..c2fb1c8b6 100644 --- a/src/core/security/security-adapter.test.ts +++ b/src/services/security/security-adapter.test.ts @@ -1,7 +1,7 @@ import { test } from "@playwright/test"; import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; -const TEST_URL = "src/core/security/security-adapter.html?random=false"; +const TEST_URL = "src/services/security/security-adapter.html?random=false"; test("unit tests contain no errors", async ({ page }) => { await expectNoJasmineFailures(page, TEST_URL); diff --git a/src/services/security/security-adapter.ts b/src/services/security/security-adapter.ts new file mode 100644 index 000000000..625a2b59e --- /dev/null +++ b/src/services/security/security-adapter.ts @@ -0,0 +1,12 @@ +import type { SceContext } from "../sce/context.ts"; + +/** @internal */ +export const passThroughSecurityAdapter = { + getTrusted: (_context, value) => value, + getTrustedMediaUrl: (value) => value, + valueOf: (value) => value, +} satisfies { + getTrusted(context: SceContext | undefined, value: T): T; + getTrustedMediaUrl(value: T): T; + valueOf(value?: T): T | undefined; +}; diff --git a/src/services/security/security.html b/src/services/security/security.html new file mode 100644 index 000000000..b197472a6 --- /dev/null +++ b/src/services/security/security.html @@ -0,0 +1,20 @@ + + + + + AngularTS Security Policy Test Runner + + + + + + + + + + + + diff --git a/src/services/security/security.spec.ts b/src/services/security/security.spec.ts new file mode 100644 index 000000000..b7e43acaa --- /dev/null +++ b/src/services/security/security.spec.ts @@ -0,0 +1,721 @@ +/// +import { createInjector } from "../../core/di/injector.ts"; +import { Angular } from "../../angular.ts"; +import { + applySecurityConfiguration, + createSecurityPolicy as createRuntimeSecurityPolicy, + createSecurityRuntimeConfiguration, + type RequestPolicyContext, + type SecurityPolicy, + type SecurityPolicyConfig, +} from "./security.ts"; + +function createDirectSecurityPolicy( + config: SecurityPolicyConfig, + baseUrl = "https://example.test/", +): SecurityPolicy { + const configuration = createSecurityRuntimeConfiguration(); + + applySecurityConfiguration(configuration, config); + + return createRuntimeSecurityPolicy(configuration, () => baseUrl); +} + +function requestContext( + overrides: Partial = {}, +): RequestPolicyContext { + return { + operation: "request", + method: "GET", + url: "/resource", + requestInit: { method: "GET" }, + ...overrides, + }; +} + +function createSecurityPolicy() { + const id = `${Date.now()}-${Math.floor(Math.random() * 1000)}`; + const moduleName = `policy_${id}`; + const app = window.angular.module(moduleName, []); + + app.config({ + $security: { + defaultDecision: "allow", + branches: ["jwt", "cookieSession"], + allowInsecureTransport: false, + }, + }); + + return createInjector(["ng", moduleName]).get("$security") as SecurityPolicy; +} + +describe("$security", () => { + beforeEach(() => { + window.angular = new Angular(); + }); + + it("applies branch order deterministically before request allow", async () => { + const $security = createSecurityPolicy(); + + const jwtFirst = await $security.check({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: { + Authorization: "Bearer abc.def", + Cookie: "session=abc", + }, + }); + + expect((jwtFirst as { scheme?: string }).scheme).toBe("jwt"); + + const app = window.angular.module("policyReversed", []); + app.config({ + $security: { + defaultDecision: "allow", + branches: ["cookieSession", "jwt"], + allowInsecureTransport: false, + }, + }); + const cookieFirst = ( + createInjector(["ng", "policyReversed"]).get( + "$security", + ) as SecurityPolicy + ).check({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: { + Authorization: "Bearer abc.def", + Cookie: "session=abc", + }, + }); + + expect(((await cookieFirst) as { scheme?: string }).scheme).toBe( + "cookieSession", + ); + }); + + it("fails requests with insecure transport by default", async () => { + const app = window.angular.module("policyTransport", []); + + app.config({ + $security: { + defaultDecision: "allow", + allowInsecureTransport: false, + }, + }); + const $security = createInjector(["ng", "policyTransport"]).get( + "$security", + ) as SecurityPolicy; + + const decision = await $security.check({ + operation: "request", + method: "GET", + url: "http://example.invalid/resource", + requestInit: { + method: "GET", + headers: {}, + credentials: "include", + }, + headers: { + Authorization: "Bearer abc.def", + }, + }); + + expect(decision.type).toBe("deny"); + expect(decision.reason).toBe("insecure transport blocked"); + }); + + it("allows insecure transport when explicitly enabled", async () => { + const app = window.angular.module("policyTransportAllowed", []); + + app.config({ + $security: { + defaultDecision: "allow", + allowInsecureTransport: true, + }, + }); + const $security = createInjector(["ng", "policyTransportAllowed"]).get( + "$security", + ) as SecurityPolicy; + + const decision = await $security.check({ + operation: "request", + method: "GET", + url: "http://example.invalid/resource", + requestInit: { + method: "GET", + headers: {}, + credentials: "include", + }, + headers: {}, + }); + + expect(decision.type).toBe("allow"); + }); + + it("dispatches generic policy checks by context operation", async () => { + const app = window.angular.module("policyGenericCheck", []); + + app.config({ + $security: { + defaultDecision: "deny", + navigation: { + rules: [ + { + state: "dashboard", + decision: "allow", + }, + ], + }, + }, + }); + const $security = createInjector(["ng", "policyGenericCheck"]).get( + "$security", + ) as SecurityPolicy; + + await expectAsync( + $security.check({ + operation: "navigation", + to: { + name: "dashboard", + }, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + await expectAsync( + $security.check({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: {}, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "deny" })); + }); + + it("falls back to defaultDecision when no branches match", async () => { + const app = window.angular.module("policyNoBranchMatch", []); + + app.config({ + $security: { + defaultDecision: "deny", + branches: ["jwt"], + }, + }); + const $security = createInjector(["ng", "policyNoBranchMatch"]).get( + "$security", + ) as SecurityPolicy; + + const decision = await $security.check({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: {}, + }); + + expect(decision.type).toBe("deny"); + expect(decision.reason).toBe( + "No configured branch matched request context", + ); + }); + + it("attaches configured jwt credentials by branch order", async () => { + const app = window.angular.module("policyJwtCredentials", []); + + app.config({ + $security: { + defaultDecision: "deny", + branches: ["jwt"], + credentials: { + jwt: () => "configured.jwt.token", + }, + }, + }); + const $security = createInjector(["ng", "policyJwtCredentials"]).get( + "$security", + ) as SecurityPolicy; + + const decision = await $security.check({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: {}, + }); + const credentials = await $security.attachRequestAuth({ + operation: "request", + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: {}, + }); + + expect(decision.type).toBe("allow"); + expect((decision as { scheme?: string }).scheme).toBe("jwt"); + expect(credentials?.credential).toEqual({ + kind: "jwt", + value: "configured.jwt.token", + }); + }); + + it("attaches configured cookie session credentials without cookie headers", async () => { + const app = window.angular.module("policyCookieCredentials", []); + + app.config({ + $security: { + defaultDecision: "deny", + branches: ["cookieSession"], + credentials: { + cookieSession: { + withCredentials: true, + }, + }, + }, + }); + const $security = createInjector(["ng", "policyCookieCredentials"]).get( + "$security", + ) as SecurityPolicy; + + const context = { + operation: "request" as const, + method: "GET", + url: "/resource", + requestInit: { + method: "GET", + headers: {}, + }, + headers: {}, + }; + const decision = await $security.check(context); + const credentials = await $security.attachRequestAuth(context); + + expect(decision.type).toBe("allow"); + expect((decision as { scheme?: string }).scheme).toBe("cookieSession"); + expect(credentials).toEqual({ withCredentials: true }); + }); + + it("applies configured navigation rules before default decisions", async () => { + const app = window.angular.module("policyNavigationRules", []); + + app.config({ + $security: { + defaultDecision: "allow", + navigation: { + rules: [ + { + state: "protected", + decision: "redirect", + target: "login", + status: 302, + reason: "login required", + }, + ], + }, + }, + }); + const $security = createInjector(["ng", "policyNavigationRules"]).get( + "$security", + ) as SecurityPolicy; + + const decision = await $security.check({ + operation: "navigation", + to: { + name: "protected", + url: "/protected", + }, + }); + + expect(decision as unknown).toEqual({ + type: "redirect", + status: 302, + reason: "login required", + target: "login", + error: undefined, + scheme: undefined, + }); + }); + + it("evaluates every credential-bearing transport form", async () => { + const secure = createDirectSecurityPolicy({ defaultDecision: "allow" }); + + await expectAsync( + secure.check( + requestContext({ + url: "//cdn.example.test/resource", + requestInit: { credentials: "include" }, + }), + ), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + await expectAsync( + secure.check( + requestContext({ + url: "https://example.test/resource", + requestInit: { credentials: "include" }, + }), + ), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + const insecureBase = createDirectSecurityPolicy( + { defaultDecision: "allow" }, + "http://example.test/", + ); + await expectAsync( + insecureBase.check( + requestContext({ + requestInit: { credentials: "include" }, + }), + ), + ).toBeResolvedTo( + jasmine.objectContaining({ + type: "deny", + reason: "insecure transport blocked", + }), + ); + + const invalidBase = createDirectSecurityPolicy( + { defaultDecision: "allow" }, + "not a valid base URL", + ); + await expectAsync( + invalidBase.check( + requestContext({ + url: "relative", + requestInit: { credentials: "include" }, + }), + ), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + }); + + it("supports basic credentials from headers and configured sources", async () => { + const fromHeader = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["basic"], + }); + const headerDecision = await fromHeader.check( + requestContext({ headers: { AUTHORIZATION: "Basic encoded" } }), + ); + + expect(headerDecision).toEqual( + jasmine.objectContaining({ type: "allow", scheme: "basic" }), + ); + await expectAsync( + fromHeader.attachRequestAuth(requestContext()), + ).toBeResolvedTo(undefined); + + const configured = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["basic"], + credentials: { basic: "user:password" }, + }); + + await expectAsync(configured.check(requestContext())).toBeResolvedTo( + jasmine.objectContaining({ type: "allow", scheme: "basic" }), + ); + await expectAsync( + configured.attachRequestAuth(requestContext()), + ).toBeResolvedTo({ + credential: { kind: "basic", value: "user:password" }, + }); + + for (const basic of [() => "", () => null] as const) { + const missing = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["basic"], + credentials: { basic }, + }); + + await expectAsync(missing.check(requestContext())).toBeResolvedTo( + jasmine.objectContaining({ type: "deny" }), + ); + await expectAsync( + missing.attachRequestAuth(requestContext()), + ).toBeResolvedTo(undefined); + } + + const optional = createDirectSecurityPolicy({ + defaultDecision: "allow", + branches: ["basic"], + }); + await expectAsync(optional.check(requestContext())).toBeResolvedTo( + jasmine.objectContaining({ type: "allow", status: undefined }), + ); + }); + + it("normalizes every cookie-session configuration form", async () => { + const cookieHeader = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["cookieSession"], + }); + + await expectAsync( + cookieHeader.check( + requestContext({ headers: { Cookie: "session=present" } }), + ), + ).toBeResolvedTo( + jasmine.objectContaining({ type: "allow", scheme: "cookieSession" }), + ); + await expectAsync( + cookieHeader.attachRequestAuth(requestContext()), + ).toBeResolvedTo(undefined); + + for (const cookieSession of [false, { enabled: false }] as const) { + const disabled = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["cookieSession"], + credentials: { cookieSession }, + }); + + await expectAsync(disabled.check(requestContext())).toBeResolvedTo( + jasmine.objectContaining({ type: "deny" }), + ); + } + + const booleanSession = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["cookieSession"], + credentials: { cookieSession: true }, + }); + await expectAsync( + booleanSession.attachRequestAuth(requestContext()), + ).toBeResolvedTo({ withCredentials: true }); + + const objectSession = createDirectSecurityPolicy({ + defaultDecision: "deny", + branches: ["cookieSession"], + credentials: { + cookieSession: { enabled: true, withCredentials: false }, + }, + }); + await expectAsync(objectSession.check(requestContext())).toBeResolvedTo( + jasmine.objectContaining({ type: "allow", scheme: "cookieSession" }), + ); + await expectAsync( + objectSession.attachRequestAuth(requestContext()), + ).toBeResolvedTo({ withCredentials: false }); + }); + + it("matches navigation state and URL rule variants", async () => { + const policy = createDirectSecurityPolicy({ + defaultDecision: "deny", + navigation: { + rules: [ + { state: ["admin", "settings"], decision: "allow" }, + { state: "blocked", decision: "deny", reason: "blocked state" }, + { url: "/public", decision: "allow" }, + { url: /^\/reports\//, decision: "allow" }, + { decision: "allow" }, + ], + }, + }); + + await expectAsync( + policy.check({ operation: "navigation", to: { name: "admin" } }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + await expectAsync( + policy.check({ operation: "navigation", to: { name: "blocked" } }), + ).toBeResolvedTo( + jasmine.objectContaining({ type: "deny", reason: "blocked state" }), + ); + await expectAsync( + policy.check({ operation: "navigation", to: { url: "/public" } }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + await expectAsync( + policy.check({ operation: "navigation", to: { url: "/reports/1" } }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + await expectAsync( + policy.check({ operation: "navigation", to: { url: "/private" } }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "deny" })); + }); + + it("enforces route authentication and unknown requirements", async () => { + const unauthenticated = createDirectSecurityPolicy({ + defaultDecision: "allow", + branches: ["jwt"], + }); + + await expectAsync( + unauthenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["authenticated"], + permissions: [], + states: ["account"], + }, + }), + ).toBeResolvedTo( + jasmine.objectContaining({ + type: "deny", + status: 401, + reason: "Authentication required", + }), + ); + + const authenticated = createDirectSecurityPolicy({ + defaultDecision: "allow", + branches: ["basic"], + credentials: { basic: "user:password" }, + }); + await expectAsync( + authenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["authenticated"], + permissions: [], + states: ["account"], + }, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + const jwtAuthenticated = createDirectSecurityPolicy({ + defaultDecision: "allow", + branches: ["jwt"], + credentials: { jwt: "jwt.token" }, + }); + await expectAsync( + jwtAuthenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["authenticated"], + permissions: [], + states: ["account"], + }, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + const cookieAuthenticated = createDirectSecurityPolicy({ + defaultDecision: "allow", + branches: ["cookieSession"], + credentials: { cookieSession: true }, + }); + await expectAsync( + cookieAuthenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["authenticated"], + permissions: [], + states: ["account"], + }, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + await expectAsync( + authenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["managed-device"], + permissions: [], + redirectTo: "unsupported", + reason: "custom requirement", + states: ["account"], + }, + }), + ).toBeResolvedTo( + jasmine.objectContaining({ + type: "redirect", + target: "unsupported", + reason: "custom requirement", + }), + ); + + await expectAsync( + authenticated.check({ + operation: "navigation", + to: { name: "account" }, + routePolicy: { + require: ["managed-device"], + permissions: [], + states: ["account"], + }, + }), + ).toBeResolvedTo( + jasmine.objectContaining({ + type: "deny", + reason: "Unknown navigation requirement 'managed-device'", + }), + ); + }); + + it("enforces route permissions from static and dynamic sources", async () => { + const denied = createDirectSecurityPolicy({ + defaultDecision: "allow", + navigation: { permissions: () => null }, + }); + const routePolicy = { + require: [], + permissions: ["reports.read"], + states: ["reports"], + }; + + await expectAsync( + denied.check({ + operation: "navigation", + to: { name: "reports" }, + routePolicy, + }), + ).toBeResolvedTo( + jasmine.objectContaining({ + type: "deny", + reason: "Permission 'reports.read' required", + }), + ); + + const allowed = createDirectSecurityPolicy({ + defaultDecision: "allow", + navigation: { permissions: ["reports.read"] }, + }); + await expectAsync( + allowed.check({ + operation: "navigation", + to: { name: "reports" }, + routePolicy, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + + const unconfigured = createDirectSecurityPolicy({ + defaultDecision: "allow", + }); + await expectAsync( + unconfigured.check({ + operation: "navigation", + to: { name: "reports" }, + routePolicy, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "deny" })); + + await expectAsync( + allowed.check({ + operation: "navigation", + to: { name: "public" }, + routePolicy: { ...routePolicy, public: true }, + }), + ).toBeResolvedTo(jasmine.objectContaining({ type: "allow" })); + }); +}); diff --git a/src/services/security/security.test.ts b/src/services/security/security.test.ts new file mode 100644 index 000000000..99f8f0ad6 --- /dev/null +++ b/src/services/security/security.test.ts @@ -0,0 +1,8 @@ +import { test } from "@playwright/test"; +import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; + +const TEST_URL = "src/services/security/security.html?random=false"; + +test("security policy unit tests contain no errors", async ({ page }) => { + await expectNoJasmineFailures(page, TEST_URL); +}); diff --git a/src/services/security/security.ts b/src/services/security/security.ts new file mode 100644 index 000000000..3abccf524 --- /dev/null +++ b/src/services/security/security.ts @@ -0,0 +1,570 @@ +import type { + GatePolicyDecisionType, + PolicyContext, + PolicyDecision, +} from "../../core/policy/policy.ts"; + +export type SecurityPolicyDecisionType = GatePolicyDecisionType; + +export interface NavigationPolicyContext extends PolicyContext { + operation: "navigation"; + from?: { + name?: string; + url?: string; + }; + to: { + name?: string; + url?: string; + params?: Record; + }; + transition?: { + id?: string; + }; + routePolicy?: SecurityNavigationRoutePolicyContext; + userAgent?: string; +} + +export interface SecurityNavigationRoutePolicyContext { + require: string[]; + permissions: string[]; + redirectTo?: string; + reason?: string; + public?: boolean; + states: string[]; +} + +export interface RequestPolicyContext extends PolicyContext { + operation: "request"; + method: string; + url: string; + requestInit: RequestInit; + headers?: Record; + hasBody?: boolean; +} + +export type SecurityPolicyContext = + | NavigationPolicyContext + | RequestPolicyContext; + +export interface SecurityCredential { + kind: "jwt" | "cookieSession" | "basic" | "custom"; + value: string; +} + +export interface SecurityRequestCredentials { + headers?: Record; + credential?: SecurityCredential; + withCredentials?: boolean; +} + +export interface SecurityPolicyDecision extends PolicyDecision { + type: SecurityPolicyDecisionType; + scheme?: "jwt" | "cookieSession" | "basic" | "custom"; + reason?: string; +} + +export interface SecurityPolicyConfig { + defaultDecision?: SecurityPolicyDecisionType; + branches?: string[]; + allowInsecureTransport?: boolean; + credentials?: SecurityPolicyCredentialConfig; + navigation?: SecurityNavigationPolicyConfig; +} + +export type SecurityCredentialSource = + | string + | (() => string | undefined | null); + +export interface SecurityCookieSessionConfig { + enabled?: boolean; + withCredentials?: boolean; +} + +export interface SecurityPolicyCredentialConfig { + jwt?: SecurityCredentialSource; + basic?: SecurityCredentialSource; + cookieSession?: boolean | SecurityCookieSessionConfig; +} + +export interface SecurityNavigationRule { + state?: string | string[]; + url?: string | RegExp; + decision: SecurityPolicyDecisionType; + reason?: string; + status?: number; + target?: string; +} + +export interface SecurityNavigationPolicyConfig { + rules?: SecurityNavigationRule[]; + permissions?: string[] | (() => string[] | undefined | null); +} + +export interface SecurityPolicy { + check(context: SecurityPolicyContext): Promise; + attachRequestAuth( + context: RequestPolicyContext, + ): Promise; +} + +/** @internal */ +export interface SecurityRuntimeConfiguration { + defaultDecision: SecurityPolicyDecisionType; + branches: string[]; + allowInsecureTransport: boolean; + credentials: SecurityPolicyCredentialConfig; + navigation: SecurityNavigationPolicyConfig; +} + +const SECURITY_DECISION_ALLOW = "allow" as const; +const DEFAULT_SECURITY_DECISION = SECURITY_DECISION_ALLOW; + +/** @internal */ +export function createSecurityRuntimeConfiguration(): SecurityRuntimeConfiguration { + return { + defaultDecision: DEFAULT_SECURITY_DECISION, + branches: [], + allowInsecureTransport: false, + credentials: {}, + navigation: {}, + }; +} + +/** @internal */ +export function applySecurityConfiguration( + configuration: SecurityRuntimeConfiguration, + config: SecurityPolicyConfig, +): void { + if (config.defaultDecision !== undefined) { + configuration.defaultDecision = config.defaultDecision; + } + + if (config.branches !== undefined) { + configuration.branches = config.branches; + } + + if (config.allowInsecureTransport !== undefined) { + configuration.allowInsecureTransport = config.allowInsecureTransport; + } + + if (config.credentials !== undefined) { + configuration.credentials = { + ...configuration.credentials, + ...config.credentials, + }; + } + + if (config.navigation !== undefined) { + configuration.navigation = { + ...configuration.navigation, + ...config.navigation, + }; + } +} + +/** + * Shared deterministic security policy used by framework services. + * + * The current implementation is intentionally minimal and explicit: + * when no config is supplied, requests and navigations are allowed. + * + * Future roadmap steps add branch-specific auth providers while preserving this + * runtime gate contract. + * + * @internal + */ +export function createSecurityPolicy( + configuration: SecurityRuntimeConfiguration, + getBaseUrl: () => string, +): SecurityPolicy { + const normalizeDecision = ( + decision: SecurityPolicyDecision, + ): SecurityPolicyDecision => { + const { type, status, reason, target, error, scheme } = decision; + + return { + type, + status, + reason, + target, + error, + scheme, + }; + }; + + const asStringHeaders = ( + headers?: Record, + ): Record => headers ?? {}; + + const getHeader = ( + headers: Record, + name: string, + ): string | undefined => { + const key = name.toLowerCase(); + + return Object.entries(headers).find( + ([headerName]) => headerName.toLowerCase() === key, + )?.[1]; + }; + + const isInsecureRequest = (context: RequestPolicyContext): boolean => { + if (context.requestInit.credentials !== "include") { + return false; + } + + const url = context.url; + + if (url.startsWith("//")) { + return false; + } + + if (url.startsWith("http://")) { + return true; + } + + if (url.startsWith("https://")) { + return false; + } + + try { + const parsed = new URL(url, getBaseUrl()); + + return parsed.protocol === "http:"; + } catch { + return false; + } + }; + + const resolveCredentialSource = ( + source: SecurityCredentialSource | undefined, + ): string | undefined => { + if (source === undefined) { + return undefined; + } + + const value = typeof source === "function" ? source() : source; + + return value === null || value === "" ? undefined : value; + }; + + const isCookieSessionEnabled = (): boolean => { + const cookieSession = configuration.credentials.cookieSession; + + if (cookieSession === undefined) { + return false; + } + + if (typeof cookieSession === "boolean") { + return cookieSession; + } + + return cookieSession.enabled !== false; + }; + + const evaluateRequestDecision = ( + context: RequestPolicyContext, + ): Promise => { + if (!configuration.allowInsecureTransport && isInsecureRequest(context)) { + return Promise.resolve( + normalizeDecision({ + type: "deny", + status: 401, + reason: "insecure transport blocked", + }), + ); + } + + const headers = asStringHeaders(context.headers); + + for (const branch of configuration.branches) { + if (branch === "jwt") { + const authorization = getHeader(headers, "authorization"); + const token = resolveCredentialSource(configuration.credentials.jwt); + + if (authorization?.toLowerCase().startsWith("bearer ") || token) { + return Promise.resolve( + normalizeDecision({ + type: "allow", + scheme: "jwt", + }), + ); + } + } + + if (branch === "basic") { + const authorization = getHeader(headers, "authorization"); + const basicCredential = resolveCredentialSource( + configuration.credentials.basic, + ); + + if ( + authorization?.toLowerCase().startsWith("basic ") || + basicCredential + ) { + return Promise.resolve( + normalizeDecision({ + type: "allow", + scheme: "basic", + }), + ); + } + } + + if (branch === "cookieSession") { + const cookie = getHeader(headers, "cookie"); + + if (cookie?.includes("session=") || isCookieSessionEnabled()) { + return Promise.resolve( + normalizeDecision({ + type: "allow", + scheme: "cookieSession", + }), + ); + } + } + } + + if (configuration.branches.length > 0) { + return Promise.resolve( + normalizeDecision({ + type: configuration.defaultDecision, + status: configuration.defaultDecision === "deny" ? 403 : undefined, + reason: "No configured branch matched request context", + }), + ); + } + + return Promise.resolve( + normalizeDecision({ type: configuration.defaultDecision }), + ); + }; + + const matchesNavigationRule = ( + rule: SecurityNavigationRule, + context: NavigationPolicyContext, + ): boolean => { + if (rule.state !== undefined) { + const states = Array.isArray(rule.state) ? rule.state : [rule.state]; + + if (!states.includes(context.to.name ?? "")) { + return false; + } + } + + if (rule.url !== undefined) { + const url = context.to.url ?? ""; + + if (typeof rule.url === "string" && rule.url !== url) { + return false; + } + + if (rule.url instanceof RegExp && !rule.url.test(url)) { + return false; + } + } + + return rule.state !== undefined || rule.url !== undefined; + }; + + const hasNavigationCredential = (): boolean => { + for (const branch of configuration.branches) { + if ( + branch === "jwt" && + resolveCredentialSource(configuration.credentials.jwt) + ) { + return true; + } + + if ( + branch === "basic" && + resolveCredentialSource(configuration.credentials.basic) + ) { + return true; + } + + if (branch === "cookieSession" && isCookieSessionEnabled()) { + return true; + } + } + + return false; + }; + + const resolveNavigationPermissions = (): string[] => { + const permissions = configuration.navigation.permissions; + + if (permissions === undefined) { + return []; + } + + const resolved = + typeof permissions === "function" ? permissions() : permissions; + + return resolved ?? []; + }; + + const routePolicyDecision = ( + context: NavigationPolicyContext, + ): SecurityPolicyDecision | undefined => { + const routePolicy = context.routePolicy; + + if (!routePolicy || routePolicy.public) { + return undefined; + } + + const redirectOrDeny = ( + reason: string, + status = 403, + ): SecurityPolicyDecision => { + if (routePolicy.redirectTo) { + return normalizeDecision({ + type: "redirect", + status: 302, + target: routePolicy.redirectTo, + reason, + }); + } + + return normalizeDecision({ + type: "deny", + status, + reason, + }); + }; + + for (const requirement of routePolicy.require) { + if (requirement === "authenticated") { + if (!hasNavigationCredential()) { + return redirectOrDeny( + routePolicy.reason ?? "Authentication required", + 401, + ); + } + + continue; + } + + return redirectOrDeny( + routePolicy.reason ?? `Unknown navigation requirement '${requirement}'`, + ); + } + + if (routePolicy.permissions.length) { + const availablePermissions = resolveNavigationPermissions(); + + for (const permission of routePolicy.permissions) { + if (!availablePermissions.includes(permission)) { + return redirectOrDeny( + routePolicy.reason ?? `Permission '${permission}' required`, + ); + } + } + } + + return undefined; + }; + + const evaluateNavigationDecision = ( + context: NavigationPolicyContext, + ): Promise => { + let allowRule: SecurityNavigationRule | undefined; + + for (const rule of configuration.navigation.rules ?? []) { + if (!matchesNavigationRule(rule, context)) { + continue; + } + + if (rule.decision === "allow") { + allowRule = rule; + continue; + } + + return Promise.resolve( + normalizeDecision({ + type: rule.decision, + status: rule.status, + reason: rule.reason, + target: rule.target, + }), + ); + } + + const policyDecision = routePolicyDecision(context); + + if (policyDecision) { + return Promise.resolve(policyDecision); + } + + if (allowRule) { + return Promise.resolve( + normalizeDecision({ + type: allowRule.decision, + status: allowRule.status, + reason: allowRule.reason, + target: allowRule.target, + }), + ); + } + + return Promise.resolve( + normalizeDecision({ type: configuration.defaultDecision }), + ); + }; + + const check = ( + context: SecurityPolicyContext, + ): Promise => + context.operation === "navigation" + ? evaluateNavigationDecision(context) + : evaluateRequestDecision(context); + + const attachRequestAuth = ( + context: RequestPolicyContext, + ): Promise => { + void context; + + for (const branch of configuration.branches) { + if (branch === "jwt") { + const token = resolveCredentialSource(configuration.credentials.jwt); + + if (token) { + return Promise.resolve({ + credential: { + kind: "jwt", + value: token, + }, + }); + } + } + + if (branch === "basic") { + const credential = resolveCredentialSource( + configuration.credentials.basic, + ); + + if (credential) { + return Promise.resolve({ + credential: { + kind: "basic", + value: credential, + }, + }); + } + } + + if (branch === "cookieSession" && isCookieSessionEnabled()) { + const cookieSession = configuration.credentials.cookieSession; + const withCredentials = + typeof cookieSession === "object" + ? cookieSession.withCredentials !== false + : true; + + return Promise.resolve({ withCredentials }); + } + } + + return Promise.resolve(undefined); + }; + + return { check, attachRequestAuth }; +} diff --git a/src/services/service-worker/README.md b/src/services/service-worker/README.md new file mode 100644 index 000000000..239d1c570 --- /dev/null +++ b/src/services/service-worker/README.md @@ -0,0 +1,88 @@ +# Service Worker Service + +`$serviceWorker` wraps browser-owned service worker registration, readiness, +update observation, controller changes, and page-to-worker messaging. It is +separate from `$worker`, which owns page-created Web Workers. + +## Public Surface + +- `$serviceWorker`: singleton lifecycle and messaging facade. +- `ServiceWorkerConfig`: registration defaults and safe observation policy. +- `ServiceWorkerRegistrationState`: template-friendly registration snapshot. +- `ServiceWorkerUpdateState`: template-friendly update snapshot. +- `ServiceWorkerMessageClient`: request/response adapter over + `$serviceWorker.post()` and `onMessage()`. + +## Lifecycle Contract + +- Browser service worker installation, activation, control, and script lifetime + remain browser-owned. +- `$serviceWorker.register(...)`, `ready()`, `update()`, and `unregister()` + expose the native lifecycle through stable promises and state snapshots. +- Runtime teardown removes AngularTS container, registration, worker-state, and + reactive-binding listeners. It does not unregister the browser-owned service + worker. +- Unsupported browsers receive a stable service with `supported: false` and + predictable async failures. + +## Reactivity Contract + +When `$serviceWorker` or a configured module wrapper is assigned to a scope or +controller model, the service binds through the scope proxy system. Browser +events and async lifecycle operations schedule watchers for `status`, +`controller`, `registration`, `registrationState`, and `updateState`. + +Destroyed observing scopes are removed opportunistically. Service worker state +continues to live at the browser/service singleton layer. + +## Message Client Adapter + +`ServiceWorkerMessageClient` is an optional adapter for apps that use a +request/response protocol with their service worker: + +```ts +const client = new ServiceWorkerMessageClient($serviceWorker, { + requestType: "app:request", + responseType: "app:response", + timeout: 5000, +}); + +const profile = await client.request({ action: "loadProfile" }); +``` + +The request envelope is: + +```ts +{ type: "app:request", id: "sw:1", payload: { action: "loadProfile" } } +``` + +The matching response envelope is: + +```ts +{ type: "app:response", id: "sw:1", ok: true, data: { name: "Ada" } } +``` + +If `ok` is false, the client rejects with +`ServiceWorkerMessageClientError` code `response-error` and preserves the +response error detail. Other stable client error codes are `timeout`, +`post-failed`, and `disposed`. + +## Policy Contract + +Current defaults never call `skipWaiting()`, never reload the page, and never +activate a waiting worker automatically. Update prompts, reload behavior, +service-worker script content, cache strategy, request replay, and retry policy +remain application or adapter policy. + +`ServiceWorkerMessageClient` only correlates messages. It does not queue +requests, retry failed posts, enforce idempotency, or persist pending work. + +## Test Harness + +- `src/services/service-worker/service-worker.spec.ts` uses fake service worker + containers, registrations, and workers. +- Tests cover unsupported behavior, registration, update detection, + controller/message events, runtime listener teardown, scope binding, + configured module wrappers, and the request/response message client. +- Browser integration tests cover real service worker registration through the + service-worker integration fixture. diff --git a/src/services/service-worker/SERVICE_WORKER_IMPLEMENTATION_ROADMAP.md b/src/services/service-worker/SERVICE_WORKER_IMPLEMENTATION_ROADMAP.md deleted file mode 100644 index 6486dc8ac..000000000 --- a/src/services/service-worker/SERVICE_WORKER_IMPLEMENTATION_ROADMAP.md +++ /dev/null @@ -1,442 +0,0 @@ -# Service Worker Implementation Roadmap - -This roadmap is executable: each slice ends with concrete acceptance checks. -Complete slices in order. Keep the first implementation focused on service -worker lifecycle and app-to-worker messaging. Do not fold service workers into -`$worker`, and do not make `$rest` depend on service workers. - -Use `src/services/SERVICE_CONTRACTS.md` as the stability gate for every slice -that adds runtime behavior. The service worker README must explicitly document -its lifecycle, reactivity, policy, failure, recovery, scheduling, native -interop, and test harness contracts before the service is marked stable. - -## Goal - -Add a small AngularTS service worker abstraction for applications that want -first-class registration, update state, controller changes, and message -exchange through dependency injection. - -The service should wrap browser service worker lifecycle APIs. It should not -generate service worker scripts, own application cache policy, or replace the -existing REST cache backend seam. - -The service should expose useful lifecycle state reactively and provide -sensible defaults for registration/update observation while leaving risky user -experience policy, such as when to activate a waiting worker or reload the -page, explicit. - -The dependency-replacement target for v1 is service-worker registration, -update, controller-change, and messaging glue. It is not a Workbox replacement -for precache generation. It should compose with `$rest`, `$workflow`, and future -supervisors through explicit adapters rather than becoming a hidden dependency -of those services. - -## Non-Goals - -- Do not extend `$worker`; Web Workers and service workers have different - lifetimes, ownership, and control semantics. -- Do not make `$http` or `$rest` automatically route through a service worker. -- Do not implement a full PWA framework in v1. -- Do not generate or bundle service worker source code in v1. -- Do not add background sync, periodic sync, push, or notification support in - v1. -- Do not assume a service worker is available in all browsers, test runners, or - file-based examples. -- Do not hide service worker update rules behind synchronous APIs. - -## Existing Boundaries - -AngularTS already has adjacent abstractions: - -- `$worker`: creates page-owned Web Workers with `post()`, `restart()`, and - `terminate()`. -- `$rest`: supports `RestBackend`, `RestCacheStore`, and `CachedRestBackend` - for cache-first, network-first, and stale-while-revalidate reads. -- persistent stores: own application state persistence. -- `$workflow`: owns command execution, diagnostics, snapshots, and recovery. - -The service worker service should interoperate with these abstractions through -small adapters and examples, not hidden coupling. - -## Slice 1: Public Contract - -Add types only. - -- `ServiceWorkerService` -- `ServiceWorkerProvider` -- `ServiceWorkerConfig` -- `ServiceWorkerRegistrationState` -- `ServiceWorkerUpdateState` -- `ServiceWorkerMessageEvent` -- `ServiceWorkerMessageTarget` -- `ServiceWorkerSupport` - -Candidate API: - -```ts -serviceWorker.supported; -serviceWorker.controller; -serviceWorker.registration; -serviceWorker.status; -serviceWorker.register(scriptUrl, options?); -serviceWorker.ready(); -serviceWorker.update(); -serviceWorker.unregister(); -serviceWorker.post(message, transfer?); -serviceWorker.onMessage(callback); -serviceWorker.onControllerChange(callback); -serviceWorker.onUpdate(callback); -``` - -Rules: - -- `supported` is a boolean based on `navigator.serviceWorker`. -- Async methods return promises. -- Event subscription methods return disposer functions. -- `post()` sends to `navigator.serviceWorker.controller` by default and fails - predictably when there is no controller. -- State objects must be plain enough for templates and generated facades. - -Acceptance: - -- Add type tests for the public service and config shapes. -- Export public types from the namespace surface. -- No runtime behavior is added in this slice. -- Run: - -```bash -./node_modules/.bin/tsc --project tsconfig.test.json -make test-namespace-js -``` - -## Slice 2: Service Skeleton - -Add `src/services/service-worker/service-worker.ts`. - -Provider: - -```ts -class ServiceWorkerProvider { - $get = [ - "$log", - "$exceptionHandler", - (log, err) => - createServiceWorkerService(navigator.serviceWorker, { log, err }), - ]; -} -``` - -Rules: - -- Use dependency injection for logging and exception handling. -- Allow an internal test double for `ServiceWorkerContainer`. -- If service workers are unsupported, return a stable no-op service where - `supported` is false and async operations reject with a stable error code. -- Do not touch `navigator.serviceWorker` during module loading. - -Acceptance: - -- Creates a supported service with a fake container. -- Creates an unsupported service without throwing. -- Unsupported `register()`, `ready()`, `update()`, `unregister()`, and `post()` - reject with stable errors. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 3: Registration Lifecycle - -Implement registration and ready handling. - -Rules: - -- `register(scriptUrl, options?)` delegates to - `navigator.serviceWorker.register()`. -- Track the latest `ServiceWorkerRegistration`. -- Normalize state from `installing`, `waiting`, and `active` workers. -- `ready()` resolves from `navigator.serviceWorker.ready`. -- `unregister()` unregisters the latest registration if present. -- `update()` calls `registration.update()` when registration exists. -- Configuration may include `scope`, `type`, and `updateViaCache`. - -Acceptance: - -- Registers with script URL and options. -- Stores registration state after register. -- Resolves ready state. -- Updates an existing registration. -- Unregisters an existing registration. -- Rejects update and unregister when no registration exists. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Slice 4: Controller And Message Events - -Add app-to-service-worker communication. - -Rules: - -- Listen to `navigator.serviceWorker` `message` events. -- Listen to `controllerchange`. -- `onMessage(callback)` receives normalized data and raw event. -- `onControllerChange(callback)` receives the current controller. -- `post(message, transfer?)` posts to the current controller. -- Do not invent request/response semantics in v1. - -Acceptance: - -- Registers and disposes message listeners. -- Registers and disposes controller-change listeners. -- Updates `controller` when `controllerchange` fires. -- Posts messages to the active controller. -- Rejects `post()` without an active controller. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Slice 5: Update Detection - -Expose update lifecycle without forcing reload behavior. - -Rules: - -- After registration, listen for `registration.updatefound`. -- Track worker state changes for installing and waiting workers. -- `onUpdate(callback)` fires when a new worker is installing, waiting, or active. -- Provide update metadata, not policy decisions. -- Do not call `skipWaiting()` automatically. -- Do not reload the page automatically. - -Candidate update state: - -```ts -{ - phase: "installing" | "waiting" | "active" | "redundant"; - worker: ServiceWorker; - registration: ServiceWorkerRegistration; -} -``` - -Acceptance: - -- Emits update state when `updatefound` fires. -- Emits state transitions for the discovered worker. -- Removes old worker state listeners when a newer update is found. -- Leaves reload and `skipWaiting` policy to application code. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Slice 6: Scope Binding And Reactivity - -Make the service template-friendly. - -Rules: - -- Expose reactive service state through plain properties: - `supported`, `status`, `controller`, and `registration`. -- If the service is assigned to a scope or controller, service worker events - should schedule scope updates. -- Use the existing scope proxy binding pattern if the service owns mutable - runtime state. -- Keep event callbacks outside digest-style assumptions. - -Acceptance: - -- Templates update after registration. -- Templates update after controller change. -- Templates update after update detection. -- Destroyed observing scopes are cleaned up opportunistically. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Slice 7: Module Registration - -Add module sugar only after service behavior is tested. - -Candidate: - -```ts -app.serviceWorker("appServiceWorker", "/sw.js", { - scope: "/", - type: "module", -}); -``` - -Rules: - -- Module registration creates an injectable service wrapper. -- Registration should remain explicit unless the config asks for auto-register. -- Dynamic config should follow the same pattern as `.worker()`, `.wasm()`, - `.sse()`, `.websocket()`, `.webTransport()`, `.machine()`, and `.workflow()`. -- Avoid surprising network work during injector creation unless - `autoRegister: true` is configured. - -Acceptance: - -- `module.serviceWorker(name, scriptUrl, config)` creates an injectable. -- Dynamic script URL and config resolve through DI. -- `autoRegister: false` does not register during injection. -- `autoRegister: true` starts registration and exposes the promise or status. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 8: Namespace And Generated Surfaces - -Add the new service to public package and language integration surfaces. - -Rules: - -- Add injection tokens for `$serviceWorker` and `$serviceWorkerProvider`. -- Register the provider in `ng.ts`. -- Export public types through `namespace.ts`. -- Update Closure externs and generated language bindings after the TypeScript - surface is stable. - -Acceptance: - -- `$serviceWorker` is injectable from the core `ng` module. -- Public symbols are available from namespace exports. -- Closure, ClojureScript, Dart, Gleam, Kotlin, and Wasm namespace parity checks - are updated or intentionally deferred with tracking notes. -- Run: - -```bash -make generated-check -make test-namespace-js -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 9: Optional Adapters - -Add adapters only if concrete examples prove they reduce application code. - -Candidates: - -- `ServiceWorkerMessageClient`: request/response helper with request ids. -- `serviceWorkerRestBackend`: REST backend that sends requests to a service - worker message protocol. -- `cacheApiRestCacheStore`: `RestCacheStore` implementation backed by the - browser Cache API. -- `$workflow` activity helper for queueing a command request through the service - worker. - -Rules: - -- Keep adapters outside the core service worker lifecycle service. -- Each adapter must have its own protocol and failure semantics. -- Do not make `$rest` or `$workflow` depend on service workers. -- Do not add request queues until retry, idempotency, and replay behavior are - explicit. - -Acceptance: - -- Each adapter has focused tests and docs. -- Adapter failures preserve the owning abstraction's error model. -- No core service worker API changes are required for adapter-specific behavior. - -## Slice 10: Browser Integration Tests - -Add real browser tests after the fake-container unit tests are stable. - -Rules: - -- Service workers require a secure context or localhost. -- Tests must use unique script URLs or scopes to avoid cross-test state leaks. -- Always unregister test registrations during cleanup. -- Avoid relying on browser update timing without explicit waits. - -Acceptance: - -- Registers a real test service worker from the dev server. -- Receives a message from the service worker. -- Posts a message to the active controller when available. -- Detects an updated service worker script. -- Cleans up registration after each test. -- Run: - -```bash -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Slice 11: Documentation - -Docs must clearly distinguish the layers: - -- Web Worker: page-owned background computation through `$worker`. -- Service worker: browser-owned page control, lifecycle, updates, and message - events through `$serviceWorker`. -- REST cache: app-owned backend/cache strategies through `RestBackend` and - `RestCacheStore`. -- Workflow: command execution and recovery; service workers can be an activity - boundary, not the workflow engine. - -Acceptance: - -- Add service docs page. -- Add registration and update prompt example. -- Add message example. -- Add REST/cache positioning note. -- Add unsupported-browser behavior note. -- Every executable example has a matching test. -- Run: - -```bash -make docs-examples-check -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -## Future Capabilities - -Do not start these until the lifecycle and messaging service is stable: - -- push subscription management -- notification permission and display helpers -- background sync queue -- periodic background sync -- navigation preload helpers -- service-worker-side helper library -- app shell precache manifest generation -- update prompt component -- request/response message client -- offline mutation queue - -Each future capability should ship as an opt-in adapter or companion service -unless it is part of the browser service worker lifecycle itself. - -## Final Readiness Gate - -Run before marking service worker support production-ready: - -```bash -make check -make coverage-check -npx playwright test src/services/service-worker/service-worker.test.ts -``` - -Service worker support is production-ready only when: - -- unsupported browsers fail predictably -- registration, update, and message lifecycles are deterministic -- tests clean up every real service worker registration -- no existing Web Worker behavior changed -- no `$rest` behavior depends on service workers -- generated language facades are fresh -- docs explain lifecycle limits and browser requirements diff --git a/src/services/service-worker/service-worker-integration-worker.js b/src/services/service-worker/service-worker-integration-worker.js new file mode 100644 index 000000000..a163e392e --- /dev/null +++ b/src/services/service-worker/service-worker-integration-worker.js @@ -0,0 +1,41 @@ +const scriptUrl = new URL(self.location.href); +const testId = scriptUrl.searchParams.get("test") ?? ""; +const version = scriptUrl.searchParams.get("version") ?? ""; + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + await self.clients.claim(); + + const clients = await self.clients.matchAll({ + includeUncontrolled: true, + type: "window", + }); + + for (const client of clients) { + client.postMessage({ + kind: "activated", + testId, + version, + }); + } + })(), + ); +}); + +self.addEventListener("message", (event) => { + if (event.data?.kind !== "ping") { + return; + } + + event.source?.postMessage({ + kind: "pong", + testId, + version, + payload: event.data.payload, + }); +}); diff --git a/src/services/service-worker/service-worker-integration.html b/src/services/service-worker/service-worker-integration.html new file mode 100644 index 000000000..278f6736e --- /dev/null +++ b/src/services/service-worker/service-worker-integration.html @@ -0,0 +1,11 @@ + + + + + AngularTS Service Worker Integration + + + +
Service worker integration test harness
+ + diff --git a/src/services/service-worker/service-worker-integration.js b/src/services/service-worker/service-worker-integration.js new file mode 100644 index 000000000..0c13dadb6 --- /dev/null +++ b/src/services/service-worker/service-worker-integration.js @@ -0,0 +1,202 @@ +import { createServiceWorkerService } from "./service-worker.ts"; + +const scope = new URL("./", location.href).href; +const timeoutMs = 10_000; + +let service = createService(); + +function createService() { + return createServiceWorkerService(navigator.serviceWorker, { + log: console, + err(error) { + throw error; + }, + }); +} + +function workerScriptUrl(testId, version) { + const url = new URL("./service-worker-integration-worker.js", location.href); + + url.searchParams.set("test", testId); + url.searchParams.set("version", version); + + return url.href; +} + +function withTimeout(promise, label) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${String(timeoutMs)}ms.`)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function waitForController() { + if (navigator.serviceWorker.controller) { + return Promise.resolve(navigator.serviceWorker.controller); + } + + return withTimeout( + new Promise((resolve) => { + const onControllerChange = () => { + navigator.serviceWorker.removeEventListener( + "controllerchange", + onControllerChange, + ); + resolve(navigator.serviceWorker.controller); + }; + + navigator.serviceWorker.addEventListener( + "controllerchange", + onControllerChange, + ); + }), + "service worker controller", + ); +} + +function waitForServiceMessage(predicate, label) { + return withTimeout( + new Promise((resolve) => { + const dispose = service.onMessage((event) => { + if (!predicate(event.data)) { + return; + } + + dispose(); + resolve(event.data); + }); + }), + label, + ); +} + +async function cleanup() { + if (!("serviceWorker" in navigator)) { + return false; + } + + const registrations = await navigator.serviceWorker.getRegistrations(); + const testRegistrations = registrations.filter( + (registration) => registration.scope === scope, + ); + + await Promise.all( + testRegistrations.map((registration) => registration.unregister()), + ); + + service = createService(); + + return testRegistrations.length > 0; +} + +async function register(testId, version) { + service = createService(); + + const registration = await service.register( + workerScriptUrl(testId, version), + { + scope, + updateViaCache: "none", + }, + ); + await service.ready(); + await waitForController(); + + return { + controller: Boolean( + service.controller ?? navigator.serviceWorker.controller, + ), + scope: registration.scope, + status: service.status, + registered: service.registrationState.registered, + active: service.registrationState.active, + }; +} + +async function registerAndExchange(testId) { + await register(testId, "message"); + + const responsePromise = waitForServiceMessage( + (data) => data?.kind === "pong" && data.testId === testId, + "service worker pong message", + ); + + await service.post({ + kind: "ping", + testId, + payload: { ok: true }, + }); + + const response = await responsePromise; + + return { + controller: Boolean( + service.controller ?? navigator.serviceWorker.controller, + ), + message: response, + status: service.status, + }; +} + +async function detectUpdate(testId) { + await register(testId, "one"); + + const observed = []; + const updatePromise = withTimeout( + new Promise((resolve) => { + const dispose = service.onUpdate((state) => { + const update = { + phase: state.phase, + waiting: state.waiting, + scriptUrl: state.worker?.scriptURL ?? "", + }; + + observed.push(update); + + if ( + update.scriptUrl.includes("version=two") && + update.phase !== undefined + ) { + dispose(); + resolve(update); + } + }); + }), + "service worker update", + ); + + await navigator.serviceWorker.register(workerScriptUrl(testId, "two"), { + scope, + updateViaCache: "none", + }); + + const update = await updatePromise; + + return { + update, + observed, + status: service.status, + }; +} + +window.serviceWorkerIntegration = { + cleanup, + detectUpdate, + register, + registerAndExchange, + supported() { + return "serviceWorker" in navigator; + }, +}; diff --git a/src/services/service-worker/service-worker-types.spec.ts b/src/services/service-worker/service-worker-types.spec.ts new file mode 100644 index 000000000..e147ceb32 --- /dev/null +++ b/src/services/service-worker/service-worker-types.spec.ts @@ -0,0 +1,141 @@ +/// +import type { + ServiceWorkerConfig, + ServiceWorkerMessageClientErrorCode, + ServiceWorkerMessageClientOptions, + ServiceWorkerMessageClientRequestOptions, + ServiceWorkerMessageEvent, + ServiceWorkerMessageRequest, + ServiceWorkerMessageResponse, + ServiceWorkerMessageTarget, + ServiceWorkerRegistrationState, + ServiceWorkerService, + ServiceWorkerSupport, + ServiceWorkerUpdateState, +} from "./service-worker.ts"; + +describe("$serviceWorker types", () => { + it("typechecks public service-worker contract shapes", () => { + const support: ServiceWorkerSupport = { + supported: false, + reason: "missing-service-worker", + }; + const config: ServiceWorkerConfig = { + scriptUrl: "/sw.js", + scope: "/app/", + type: "module", + updateViaCache: "imports", + autoRegister: true, + checkForUpdatesOnRegister: true, + }; + const registrationState: ServiceWorkerRegistrationState = { + registered: true, + scope: "/app/", + updateViaCache: "none", + active: "activated", + waiting: "installed", + }; + const updateState: ServiceWorkerUpdateState = { + checking: false, + waiting: true, + controllerChanged: false, + lastCheckedAt: Date.now(), + }; + const target: ServiceWorkerMessageTarget = "controller"; + const clientOptions: ServiceWorkerMessageClientOptions = { + requestType: "app:request", + responseType: "app:response", + timeout: 1000, + createId: () => "id", + target, + }; + const requestOptions: ServiceWorkerMessageClientRequestOptions = { + transfer: [], + timeout: 500, + target: "waiting", + }; + const clientRequest: ServiceWorkerMessageRequest<{ action: "sync" }> = { + type: "app:request", + id: "id", + payload: { action: "sync" }, + }; + const clientResponse: ServiceWorkerMessageResponse<{ ok: true }> = { + type: "app:response", + id: "id", + ok: true, + data: { ok: true }, + }; + const clientErrorCode: ServiceWorkerMessageClientErrorCode = "timeout"; + const service = null as unknown as ServiceWorkerService; + + service.register("/sw.js", config); + service.register(new URL("https://example.test/sw.js"), { + updateViaCache: "all", + }); + service.ready(); + service.update(); + service.unregister(); + service.post({ type: "ping" }); + service.post({ type: "ping" }, [], target); + service.onMessage<{ ok: true }>((event) => { + const ok: true = event.data.ok; + + void ok; + }); + service.onControllerChange((controller) => { + const current: ServiceWorker | null = controller; + + void current; + }); + service.onUpdate((state) => { + const waiting: boolean = state.waiting; + + void waiting; + }); + + const status: ServiceWorkerService["status"] = service.status; + const message: ServiceWorkerMessageEvent = { + data: "ready", + event: null as unknown as MessageEvent, + source: null, + }; + + // @ts-expect-error invalid support reason. + support.reason = "unsupported-browser"; + // @ts-expect-error invalid service worker script type. + config.type = "shared"; + // @ts-expect-error invalid update cache policy. + config.updateViaCache = "cache-first"; + // @ts-expect-error invalid target. + service.post("ping", [], "registration"); + // @ts-expect-error invalid status. + service.status = "activating"; + // @ts-expect-error invalid client target. + clientOptions.target = "registration"; + // @ts-expect-error invalid request option target. + requestOptions.target = "registration"; + // @ts-expect-error invalid client error code. + const invalidClientErrorCode: ServiceWorkerMessageClientErrorCode = + "failed"; + + expect(registrationState.registered).toBeTrue(); + expect(updateState.waiting).toBeTrue(); + expect(status).toBeDefined(); + expect(message.data).toBe("ready"); + expect(clientRequest.payload.action).toBe("sync"); + expect(clientResponse.data?.ok).toBeTrue(); + expect(clientErrorCode).toBe("timeout"); + void invalidClientErrorCode; + }); + + it("typechecks ng namespace aliases", () => { + const config: ng.ServiceWorkerConfig = { + scriptUrl: "/sw.js", + }; + const service = null as unknown as ng.ServiceWorkerService; + const state: ng.ServiceWorkerUpdateState = service.updateState; + + void config; + void state; + }); +}); diff --git a/src/services/service-worker/service-worker.html b/src/services/service-worker/service-worker.html new file mode 100644 index 000000000..2c8529f53 --- /dev/null +++ b/src/services/service-worker/service-worker.html @@ -0,0 +1,22 @@ + + + + + AngularTS Test Runner + + + + + + + + + + + +
+ + diff --git a/src/services/service-worker/service-worker.spec.ts b/src/services/service-worker/service-worker.spec.ts new file mode 100644 index 000000000..fb372d66a --- /dev/null +++ b/src/services/service-worker/service-worker.spec.ts @@ -0,0 +1,1373 @@ +/// +import { Angular } from "../../angular.ts"; +import { createInjector } from "../../core/di/injector.ts"; +import { SCOPE_PROXY_BIND } from "../../core/scope/scope.ts"; +import { dealoc } from "../../shared/dom.ts"; +import { wait } from "../../shared/test-utils.ts"; +import { + createConfiguredServiceWorkerService, + createServiceWorkerService, + destroyServiceWorkerService, + ServiceWorkerMessageClient, + ServiceWorkerMessageClientError, + ServiceWorkerError, +} from "./service-worker.ts"; +import type { ServiceWorkerMessageEvent } from "./service-worker.ts"; + +function createLog(): ng.LogService { + return { + debug: jasmine.createSpy("debug"), + error: jasmine.createSpy("error"), + info: jasmine.createSpy("info"), + log: jasmine.createSpy("log"), + warn: jasmine.createSpy("warn"), + }; +} + +function createExceptionHandler(): ng.ExceptionHandlerService { + return (() => undefined) as unknown as ng.ExceptionHandlerService; +} + +function createReactiveScope(): ng.Scope { + window.angular = new Angular(); + + return createInjector(["ng"]).get("$rootScope") as ng.Scope; +} + +async function expectUnsupported(promise: Promise): Promise { + try { + await promise; + fail("Expected service-worker operation to reject."); + } catch (error) { + expect(error instanceof ServiceWorkerError).toBeTrue(); + expect((error as ServiceWorkerError).name).toBe("ServiceWorkerError"); + expect((error as ServiceWorkerError).code).toBe("unsupported"); + } +} + +async function expectServiceWorkerError( + promise: Promise, + code: ServiceWorkerError["code"], +): Promise { + try { + await promise; + fail(`Expected service-worker operation to reject with ${code}.`); + } catch (error) { + expect(error instanceof ServiceWorkerError).toBeTrue(); + expect((error as ServiceWorkerError).code).toBe(code); + } +} + +async function expectMessageClientError( + promise: Promise, + code: ServiceWorkerMessageClientError["code"], +): Promise { + try { + await promise; + fail(`Expected service-worker message client to reject with ${code}.`); + } catch (error) { + expect(error instanceof ServiceWorkerMessageClientError).toBeTrue(); + expect((error as ServiceWorkerMessageClientError).code).toBe(code); + } +} + +interface FakeRegistrationOptions { + scope?: string; + updateViaCache?: ServiceWorkerUpdateViaCache; + installing?: ServiceWorker; + waiting?: ServiceWorker; + active?: ServiceWorker; + update?: () => Promise; + unregister?: () => Promise; +} + +interface FakeServiceWorkerContainer extends ServiceWorkerContainer { + listeners: Partial>; + dispatch(type: string, event?: Event): void; +} + +interface FakeServiceWorker extends ServiceWorker { + listeners: Partial>; + dispatch(type: string, event?: Event): void; + skipWaiting: jasmine.Spy; +} + +interface FakeServiceWorkerRegistration extends ServiceWorkerRegistration { + listeners: Partial>; + dispatch(type: string, event?: Event): void; +} + +function createWorker(state: ServiceWorkerState): FakeServiceWorker { + const listeners: Partial> = {}; + const worker = { + state, + postMessage: jasmine.createSpy("postMessage"), + skipWaiting: jasmine.createSpy("skipWaiting"), + listeners, + addEventListener(type: string, listener: EventListener) { + listeners[type] ??= []; + listeners[type].push(listener); + }, + removeEventListener(type: string, listener: EventListener) { + listeners[type] = (listeners[type] ?? []).filter( + (entry) => entry !== listener, + ); + }, + dispatch(type: string, event: Event = new Event(type)) { + (listeners[type] ?? []).forEach((listener) => listener(event)); + }, + } as unknown as FakeServiceWorker; + + return worker; +} + +function setWorkerState( + worker: FakeServiceWorker, + state: ServiceWorkerState, +): void { + (worker as unknown as { state: ServiceWorkerState }).state = state; +} + +function createContainer( + controller: ServiceWorker | null = null, +): FakeServiceWorkerContainer { + const listeners: Partial> = {}; + const container = { + controller, + listeners, + addEventListener(type: string, listener: EventListener) { + listeners[type] ??= []; + listeners[type].push(listener); + }, + removeEventListener(type: string, listener: EventListener) { + listeners[type] = (listeners[type] ?? []).filter( + (entry) => entry !== listener, + ); + }, + dispatch(type: string, event: Event = new Event(type)) { + (listeners[type] ?? []).forEach((listener) => listener(event)); + }, + } as FakeServiceWorkerContainer; + + return container; +} + +function createRegistration( + options: FakeRegistrationOptions = {}, +): FakeServiceWorkerRegistration { + const listeners: Partial> = {}; + const registration = { + scope: options.scope ?? "/", + updateViaCache: options.updateViaCache ?? "imports", + installing: options.installing ?? null, + waiting: options.waiting ?? null, + active: options.active ?? null, + update: + options.update ?? + jasmine + .createSpy("update") + .and.resolveTo(null as unknown as ServiceWorkerRegistration), + unregister: + options.unregister ?? jasmine.createSpy("unregister").and.resolveTo(true), + listeners, + addEventListener(type: string, listener: EventListener) { + listeners[type] ??= []; + listeners[type].push(listener); + }, + removeEventListener(type: string, listener: EventListener) { + listeners[type] = (listeners[type] ?? []).filter( + (entry) => entry !== listener, + ); + }, + dispatch(type: string, event: Event = new Event(type)) { + (listeners[type] ?? []).forEach((listener) => listener(event)); + }, + } as unknown as FakeServiceWorkerRegistration; + + return registration; +} + +function setRegistrationWorker( + registration: FakeServiceWorkerRegistration, + key: "installing" | "waiting" | "active", + worker: ServiceWorker | null, +): void { + (registration as unknown as Record)[key] = + worker; +} + +describe("$serviceWorker", () => { + it("creates a supported service with a fake container", () => { + const controller = { state: "activated" } as ServiceWorker; + const container = { + controller, + } as ServiceWorkerContainer; + const log = createLog(); + const err = createExceptionHandler(); + + const service = createServiceWorkerService(container, { log, err }); + + expect(service.supported).toBeTrue(); + expect(service.support).toEqual({ supported: true }); + expect(service.status).toBe("idle"); + expect(service.controller).toBe(controller); + expect(service.registration).toBeNull(); + expect(service.registrationState).toEqual({ registered: false }); + expect(service.updateState).toEqual({ + checking: false, + waiting: false, + controllerChanged: false, + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("schedules bound scope watchers after registration state changes", async () => { + const registration = createRegistration({ + scope: "/app/", + waiting: createWorker("installed"), + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const scope = createReactiveScope(); + const statuses: string[] = []; + const registered: boolean[] = []; + + scope.sw = service; + const scopedService = scope.sw as ng.ServiceWorkerService; + scope.$watch("sw.status", (value) => { + statuses.push(value as string); + }); + scope.$watch("sw.registrationState.registered", (value) => { + registered.push(value as boolean); + }); + await wait(); + statuses.length = 0; + registered.length = 0; + + await scopedService.register("/sw.js"); + await wait(); + + expect(statuses).toContain("registered"); + expect(registered).toContain(true); + }); + + it("schedules bound scope watchers after controller changes", async () => { + const nextController = createWorker("activated"); + const container = createContainer(null); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const scope = createReactiveScope(); + const controllers: Array = []; + + scope.sw = service; + const scopedService = scope.sw as ng.ServiceWorkerService; + scope.$watch("sw.controller", (value) => { + controllers.push(value as ServiceWorker | null); + }); + await wait(); + controllers.length = 0; + + (container as unknown as { controller: ServiceWorker | null }).controller = + nextController; + container.dispatch("controllerchange"); + await wait(); + + expect(service.controller).toBe(nextController); + expect(scopedService.controller?.state).toBe("activated"); + expect(controllers.length).toBe(1); + expect(controllers[0]?.state).toBe("activated"); + }); + + it("schedules bound scope watchers after update detection", async () => { + const worker = createWorker("installing"); + const registration = createRegistration({ + scope: "/app/", + installing: worker, + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const scope = createReactiveScope(); + const phases: Array = []; + + scope.sw = service; + const scopedService = scope.sw as ng.ServiceWorkerService; + scope.$watch("sw.updateState.phase", (value) => { + phases.push(value as ServiceWorkerState | undefined); + }); + await wait(); + phases.length = 0; + + await scopedService.register("/sw.js"); + registration.dispatch("updatefound"); + await wait(); + + expect(phases).toContain("installing"); + }); + + it("cleans up destroyed bound scopes opportunistically", async () => { + const registration = createRegistration({ scope: "/app/" }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const scope = createReactiveScope(); + const statuses: string[] = []; + + scope.sw = service; + const scopedService = scope.sw as ng.ServiceWorkerService; + scope.$watch("sw.status", (value) => { + statuses.push(value as string); + }); + await wait(); + statuses.length = 0; + + scope.$destroy(); + await scopedService.register("/sw.js"); + await wait(); + + expect(statuses).toEqual([]); + }); + + it("registers with script URL and registration options", async () => { + const registration = createRegistration({ + scope: "/app/", + updateViaCache: "none", + installing: createWorker("installing"), + waiting: createWorker("installed"), + active: createWorker("activated"), + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + const result = await service.register("/sw.js", { + scope: "/app/", + type: "module", + updateViaCache: "none", + }); + + expect(result).toBe(registration); + expect(container.register).toHaveBeenCalledWith("/sw.js", { + scope: "/app/", + type: "module", + updateViaCache: "none", + }); + expect(service.registration).toBe(registration); + expect(service.registrationState).toEqual({ + registered: true, + scope: "/app/", + updateViaCache: "none", + installing: "installing", + waiting: "installed", + active: "activated", + }); + expect(service.updateState.waiting).toBeTrue(); + }); + + it("resolves ready state and stores the ready registration", async () => { + const registration = createRegistration({ + scope: "/ready/", + updateViaCache: "imports", + active: createWorker("activated"), + }); + const container = { + controller: null, + ready: Promise.resolve(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + await expectAsync(service.ready()).toBeResolvedTo(registration); + + expect(service.registration).toBe(registration); + expect(service.registrationState).toEqual({ + registered: true, + scope: "/ready/", + updateViaCache: "imports", + active: "activated", + }); + }); + + it("updates an existing registration", async () => { + const updatedRegistration = createRegistration({ + scope: "/updated/", + updateViaCache: "all", + waiting: createWorker("installed"), + }); + const registration = createRegistration({ + scope: "/app/", + update: jasmine.createSpy("update").and.resolveTo(updatedRegistration), + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + await service.register("/sw.js"); + await expectAsync(service.update()).toBeResolvedTo(updatedRegistration); + + expect(registration.update).toHaveBeenCalled(); + expect(service.updateState.checking).toBeFalse(); + expect(service.updateState.lastCheckedAt).toEqual(jasmine.any(Number)); + expect(service.updateState.waiting).toBeTrue(); + expect(service.registration).toBe(updatedRegistration); + expect(service.registrationState).toEqual({ + registered: true, + scope: "/updated/", + updateViaCache: "all", + waiting: "installed", + }); + }); + + it("unregisters an existing registration and clears state", async () => { + const registration = createRegistration({ + scope: "/app/", + waiting: createWorker("installed"), + unregister: jasmine.createSpy("unregister").and.resolveTo(true), + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + await service.register("/sw.js"); + await expectAsync(service.unregister()).toBeResolvedTo(true); + + expect(registration.unregister).toHaveBeenCalled(); + expect(service.registration).toBeNull(); + expect(service.registrationState).toEqual({ registered: false }); + expect(service.updateState.waiting).toBeFalse(); + }); + + it("rejects update and unregister when no registration exists", async () => { + const container = { + controller: null, + } as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + await expectServiceWorkerError(service.update(), "no-registration"); + await expectServiceWorkerError(service.unregister(), "no-registration"); + expect(service.updateState.errorCode).toBe("no-registration"); + }); + + it("registers and disposes message listeners", () => { + const container = createContainer(); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const received: ServiceWorkerMessageEvent<{ ok: true }>[] = []; + + const dispose = service.onMessage<{ ok: true }>((event) => { + received.push(event); + }); + const event = { + data: { ok: true }, + source: null, + } as MessageEvent<{ ok: true }>; + + container.dispatch("message", event); + dispose(); + container.dispatch("message", { + data: { ok: true }, + source: null, + } as MessageEvent<{ ok: true }>); + + expect(received.length).toBe(1); + expect(received[0].data).toEqual({ ok: true }); + expect(received[0].event).toBe(event); + }); + + it("registers and disposes controller-change listeners", () => { + const initialController = createWorker("activated"); + const nextController = createWorker("activating"); + const container = createContainer(initialController); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const controllers: (ServiceWorker | null)[] = []; + + const dispose = service.onControllerChange((controller) => { + controllers.push(controller); + }); + + (container as unknown as { controller: ServiceWorker | null }).controller = + nextController; + container.dispatch("controllerchange"); + dispose(); + (container as unknown as { controller: ServiceWorker | null }).controller = + null; + container.dispatch("controllerchange"); + + expect(service.controller).toBeNull(); + expect(service.updateState.controllerChanged).toBeTrue(); + expect(controllers).toEqual([nextController]); + }); + + it("releases framework listeners without unregistering the browser worker", async () => { + const worker = createWorker("installing"); + const registration = createRegistration({ installing: worker }); + const container = createContainer(); + container.register = jasmine + .createSpy("register") + .and.resolveTo(registration); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + service.onMessage(() => undefined); + service.onControllerChange(() => undefined); + service.onUpdate(() => undefined); + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + expect(container.listeners.controllerchange?.length).toBe(1); + expect(container.listeners.message?.length).toBe(1); + expect(registration.listeners.updatefound?.length).toBe(1); + expect(worker.listeners.statechange?.length).toBe(1); + + destroyServiceWorkerService(service); + destroyServiceWorkerService(service); + + expect(container.listeners.controllerchange).toEqual([]); + expect(container.listeners.message).toEqual([]); + expect(registration.listeners.updatefound).toEqual([]); + expect(worker.listeners.statechange).toEqual([]); + expect(registration.unregister).not.toHaveBeenCalled(); + + service.onMessage(() => undefined); + service.onControllerChange(() => undefined); + service.onUpdate(() => undefined); + expect(container.listeners.message).toEqual([]); + }); + + it("posts messages to the active controller", async () => { + const controller = createWorker("activated"); + const service = createServiceWorkerService(createContainer(controller), { + log: createLog(), + err: createExceptionHandler(), + }); + const transfer = [new ArrayBuffer(1)]; + + await expectAsync(service.post({ type: "ping" }, transfer)).toBeResolved(); + + expect(controller.postMessage).toHaveBeenCalledWith( + { type: "ping" }, + transfer as unknown as StructuredSerializeOptions, + ); + }); + + it("rejects post without an active controller", async () => { + const service = createServiceWorkerService(createContainer(null), { + log: createLog(), + err: createExceptionHandler(), + }); + + await expectServiceWorkerError( + service.post({ type: "ping" }), + "no-controller", + ); + }); + + it("correlates service worker message client requests and responses", async () => { + const controller = createWorker("activated"); + const container = createContainer(controller); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service, { + createId: () => "request-1", + }); + + const responsePromise = client.request<{ accepted: true }>({ + action: "sync", + }); + + expect(client.pending).toBe(1); + const postMessage = controller.postMessage as jasmine.Spy; + expect(postMessage).toHaveBeenCalled(); + expect(postMessage.calls.mostRecent().args).toEqual([ + { + type: "angular-ts:service-worker:request", + id: "request-1", + payload: { action: "sync" }, + }, + [], + ]); + + container.dispatch("message", { + data: { + type: "other", + id: "request-1", + ok: true, + data: { accepted: false }, + }, + source: null, + } as MessageEvent); + + expect(client.pending).toBe(1); + + container.dispatch("message", { + data: { + type: "angular-ts:service-worker:response", + id: "request-1", + ok: true, + data: { accepted: true }, + }, + source: null, + } as MessageEvent); + + await expectAsync(responsePromise).toBeResolvedTo({ accepted: true }); + expect(client.pending).toBe(0); + + client.dispose(); + }); + + it("rejects service worker message client response errors", async () => { + const controller = createWorker("activated"); + const container = createContainer(controller); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service, { + createId: () => "request-2", + }); + + const responsePromise = client.request("payload"); + + container.dispatch("message", { + data: { + type: "angular-ts:service-worker:response", + id: "request-2", + ok: false, + error: { reason: "denied" }, + }, + source: null, + } as MessageEvent); + + await expectMessageClientError(responsePromise, "response-error"); + expect(client.pending).toBe(0); + + client.dispose(); + }); + + it("rejects service worker message client post failures", async () => { + const service = createServiceWorkerService(createContainer(null), { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service, { + createId: () => "request-3", + }); + + await expectMessageClientError(client.request("payload"), "post-failed"); + expect(client.pending).toBe(0); + + client.dispose(); + }); + + it("times out unanswered service worker message client requests", async () => { + const controller = createWorker("activated"); + const service = createServiceWorkerService(createContainer(controller), { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service, { + createId: () => "request-4", + timeout: 1, + }); + + await expectMessageClientError(client.request("payload"), "timeout"); + expect(client.pending).toBe(0); + + client.dispose(); + }); + + it("disposes service worker message client listeners and pending requests", async () => { + const controller = createWorker("activated"); + const container = createContainer(controller); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service, { + createId: () => "request-5", + }); + + const responsePromise = client.request("payload"); + + expect(container.listeners.message?.length).toBe(1); + + client.dispose(); + + expect(client.disposed).toBeTrue(); + expect(client.pending).toBe(0); + expect(container.listeners.message).toEqual([]); + await expectMessageClientError(responsePromise, "disposed"); + await expectMessageClientError(client.request("payload"), "disposed"); + }); + + it("emits update state when updatefound fires", async () => { + const worker = createWorker("installing"); + const registration = createRegistration({ + scope: "/app/", + installing: worker, + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const updates: ng.ServiceWorkerUpdateState[] = []; + + service.onUpdate((state) => { + updates.push({ ...state }); + }); + + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + expect(updates.length).toBe(1); + expect(updates[0].phase).toBe("installing"); + expect(updates[0].worker).toBe(worker); + expect(updates[0].registration).toBe(registration); + expect(service.updateState.phase).toBe("installing"); + expect(service.registrationState.installing).toBe("installing"); + }); + + it("emits state transitions for the discovered worker", async () => { + const worker = createWorker("installing"); + const registration = createRegistration({ + scope: "/app/", + installing: worker, + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const phases: Array = []; + + service.onUpdate((state) => { + phases.push(state.phase); + }); + + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + setRegistrationWorker(registration, "installing", null); + setRegistrationWorker(registration, "waiting", worker); + setWorkerState(worker, "installed"); + worker.dispatch("statechange"); + + setRegistrationWorker(registration, "waiting", null); + setRegistrationWorker(registration, "active", worker); + setWorkerState(worker, "activated"); + worker.dispatch("statechange"); + + expect(phases).toEqual(["installing", "installed", "activated"]); + expect(service.registrationState.waiting).toBeUndefined(); + expect(service.registrationState.active).toBe("activated"); + expect(service.updateState.waiting).toBeFalse(); + }); + + it("removes old worker state listeners when a newer update is found", async () => { + const oldWorker = createWorker("installing"); + const newWorker = createWorker("installing"); + const registration = createRegistration({ + scope: "/app/", + installing: oldWorker, + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const phases: Array = []; + + service.onUpdate((state) => { + phases.push(state.phase); + }); + + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + setRegistrationWorker(registration, "installing", newWorker); + registration.dispatch("updatefound"); + + setWorkerState(oldWorker, "redundant"); + oldWorker.dispatch("statechange"); + + setWorkerState(newWorker, "installed"); + newWorker.dispatch("statechange"); + + expect(phases).toEqual(["installing", "installing", "installed"]); + }); + + it("does not activate or reload automatically when updates are discovered", async () => { + const worker = createWorker("installing"); + const registration = createRegistration({ + scope: "/app/", + installing: worker, + }); + const container = { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer; + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + + await service.register("/sw.js"); + registration.dispatch("updatefound"); + setWorkerState(worker, "installed"); + worker.dispatch("statechange"); + + expect(worker.skipWaiting).not.toHaveBeenCalled(); + }); + + it("creates an unsupported service without throwing", () => { + const log = createLog(); + const err = createExceptionHandler(); + + const service = createServiceWorkerService(undefined, { log, err }); + + expect(service.supported).toBeFalse(); + expect(service.support.supported).toBeFalse(); + expect(service.controller).toBeNull(); + expect(service.registration).toBeNull(); + expect(log.warn).toHaveBeenCalledWith( + "Service workers are not supported in this environment.", + ); + }); + + it("rejects unsupported async operations with stable errors", async () => { + const service = createServiceWorkerService(undefined, { + log: createLog(), + err: createExceptionHandler(), + }); + + await expectUnsupported(service.register("/sw.js")); + await expectUnsupported(service.ready()); + await expectUnsupported(service.update()); + await expectUnsupported(service.unregister()); + await expectUnsupported(service.post({ type: "ping" })); + }); + + it("returns no-op disposers for event subscriptions in the skeleton", () => { + const service = createServiceWorkerService(undefined, { + log: createLog(), + err: createExceptionHandler(), + }); + + expect(service.onMessage(() => undefined)()).toBeUndefined(); + expect(service.onControllerChange(() => undefined)()).toBeUndefined(); + expect(service.onUpdate(() => undefined)()).toBeUndefined(); + }); + + it("is injectable from the default ng module", () => { + const el = document.getElementById("app")!; + el.innerHTML = ""; + + const angular = new Angular(); + let service: ng.ServiceWorkerService | undefined; + + angular + .bootstrap(el, []) + .invoke(($serviceWorker: ng.ServiceWorkerService) => { + service = $serviceWorker; + }); + + expect(service).toBeDefined(); + expect(service!.support.supported).toBe(service!.supported); + + dealoc(el); + }); + + it("preserves native failures with stable operation error codes", async () => { + const registerService = createServiceWorkerService( + { + controller: null, + register: jasmine + .createSpy("register") + .and.rejectWith(new Error("register failed")), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await expectServiceWorkerError( + registerService.register("/sw.js"), + "register-failed", + ); + expect(registerService.status).toBe("error"); + + const readyService = createServiceWorkerService( + { + controller: null, + ready: Promise.reject(new Error("ready failed")), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await expectServiceWorkerError(readyService.ready(), "ready-failed"); + + const updateRegistration = createRegistration({ + update: jasmine.createSpy("update").and.rejectWith(new Error("update")), + }); + const updateService = createServiceWorkerService( + { + controller: null, + register: jasmine + .createSpy("register") + .and.resolveTo(updateRegistration), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await updateService.register("/sw.js"); + await expectServiceWorkerError(updateService.update(), "update-failed"); + expect(updateService.updateState.errorCode).toBe("update-failed"); + expect(updateService.updateState.error).toEqual(jasmine.any(Error)); + expect(updateService.updateState.checking).toBeFalse(); + + const unregisterRegistration = createRegistration({ + unregister: jasmine + .createSpy("unregister") + .and.rejectWith(new Error("unregister")), + }); + const unregisterService = createServiceWorkerService( + { + controller: null, + register: jasmine + .createSpy("register") + .and.resolveTo(unregisterRegistration), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await unregisterService.register("/sw.js"); + await expectServiceWorkerError( + unregisterService.unregister(), + "unregister-failed", + ); + }); + + it("checks for updates on registration when configured", async () => { + const registration = createRegistration(); + + registration.update = jasmine + .createSpy("update") + .and.resolveTo(registration); + const service = createServiceWorkerService( + { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await service.register("/sw.js", { checkForUpdatesOnRegister: true }); + + expect(registration.update).toHaveBeenCalledTimes(1); + }); + + it("posts to each explicit worker lifecycle target", async () => { + const installing = createWorker("installing"); + const waiting = createWorker("installed"); + const active = createWorker("activated"); + const registration = createRegistration({ installing, waiting, active }); + const service = createServiceWorkerService( + { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + + await expectServiceWorkerError( + service.post("active", undefined, "active"), + "no-controller", + ); + await expectServiceWorkerError( + service.post("installing", undefined, "installing"), + "no-controller", + ); + await expectServiceWorkerError( + service.post("waiting", undefined, "waiting"), + "no-controller", + ); + + await service.register("/sw.js"); + await service.post("active", undefined, "active"); + await service.post("installing", undefined, "installing"); + await service.post("waiting", undefined, "waiting"); + + expect(active.postMessage).toHaveBeenCalledWith( + "active", + [] as unknown as StructuredSerializeOptions, + ); + expect(installing.postMessage).toHaveBeenCalledWith( + "installing", + [] as unknown as StructuredSerializeOptions, + ); + expect(waiting.postMessage).toHaveBeenCalledWith( + "waiting", + [] as unknown as StructuredSerializeOptions, + ); + }); + + it("discovers waiting and active workers when no installer exists", async () => { + const waiting = createWorker("installed"); + const active = createWorker("activated"); + const registration = createRegistration({ waiting }); + const service = createServiceWorkerService( + { + controller: null, + register: jasmine.createSpy("register").and.resolveTo(registration), + } as unknown as ServiceWorkerContainer, + { log: createLog(), err: createExceptionHandler() }, + ); + const phases: ServiceWorkerState[] = []; + + service.onUpdate((state) => phases.push(state.phase!)); + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + setRegistrationWorker(registration, "waiting", null); + setRegistrationWorker(registration, "active", active); + registration.dispatch("updatefound"); + + expect(phases).toEqual(["installed", "activated"]); + }); + + it("routes event callback failures through the exception handler", async () => { + const err = jasmine.createSpy("err"); + const worker = createWorker("installing"); + const registration = createRegistration({ installing: worker }); + const container = createContainer(); + + container.register = jasmine + .createSpy("register") + .and.resolveTo(registration); + const service = createServiceWorkerService(container, { + log: createLog(), + err: err as unknown as ng.ExceptionHandlerService, + }); + + service.onMessage(() => { + throw new Error("message callback"); + }); + service.onControllerChange(() => { + throw new Error("controller callback"); + }); + service.onUpdate(() => { + throw new Error("update callback"); + }); + + container.dispatch("message", new MessageEvent("message")); + container.dispatch("controllerchange"); + await service.register("/sw.js"); + registration.dispatch("updatefound"); + + expect(err).toHaveBeenCalledTimes(3); + }); + + it("keeps stale async and browser callbacks inert after destruction", async () => { + let resolveRegistration!: (registration: ServiceWorkerRegistration) => void; + const worker = createWorker("installing"); + const registration = createRegistration({ installing: worker }); + const registerPromise = new Promise( + (resolve) => { + resolveRegistration = resolve; + }, + ); + const container = createContainer(); + + container.register = jasmine + .createSpy("register") + .and.returnValue(registerPromise); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const operation = service.register("/sw.js"); + + destroyServiceWorkerService(service); + resolveRegistration(registration); + await operation; + + ( + service as unknown as { + [SCOPE_PROXY_BIND](handler: ng.Scope): void; + } + )[SCOPE_PROXY_BIND](createReactiveScope()); + + expect(service.onUpdate(() => undefined)()).toBeUndefined(); + + const trackedRegistration = createRegistration({ installing: worker }); + const trackedContainer = createContainer(); + + trackedContainer.register = jasmine + .createSpy("register") + .and.resolveTo(trackedRegistration); + const trackedService = createServiceWorkerService(trackedContainer, { + log: createLog(), + err: createExceptionHandler(), + }); + + await trackedService.register("/tracked.js"); + const staleUpdateFound = trackedRegistration.listeners.updatefound![0]; + + destroyServiceWorkerService(trackedService); + staleUpdateFound(new Event("updatefound")); + }); + + it("makes message client filtering and disposal idempotent", async () => { + const controller = createWorker("activated"); + const container = createContainer(controller); + const service = createServiceWorkerService(container, { + log: createLog(), + err: createExceptionHandler(), + }); + const client = new ServiceWorkerMessageClient(service); + const response = client.request("payload"); + const request = (controller.postMessage as jasmine.Spy).calls.mostRecent() + .args[0] as { id: string }; + + expect(request.id).toBe("sw:1"); + container.dispatch("message", new MessageEvent("message", { data: null })); + container.dispatch( + "message", + new MessageEvent("message", { + data: { + type: "angular-ts:service-worker:response", + id: "unknown", + }, + }), + ); + container.dispatch( + "message", + new MessageEvent("message", { + data: { + type: "angular-ts:service-worker:response", + id: request.id, + data: "done", + }, + }), + ); + + await expectAsync(response).toBeResolvedTo("done"); + + const dispose = service.onMessage(() => undefined); + + dispose(); + dispose(); + client.dispose(); + client.dispose(); + }); + + it("ignores a late post rejection after its response has resolved", async () => { + let messageCallback!: (event: ServiceWorkerMessageEvent) => void; + let rejectPost!: (error: unknown) => void; + const postResult = new Promise((_resolve, reject) => { + rejectPost = reject; + }); + const service = { + onMessage(callback: (event: ServiceWorkerMessageEvent) => void) { + messageCallback = callback; + return () => undefined; + }, + post() { + return postResult; + }, + } as unknown as ng.ServiceWorkerService; + const client = new ServiceWorkerMessageClient(service, { + createId: () => "late", + }); + const response = client.request("payload"); + + messageCallback({ + data: { + type: "angular-ts:service-worker:response", + id: "late", + data: "done", + }, + event: new MessageEvent("message"), + }); + rejectPost(new Error("late post failure")); + + await expectAsync(response).toBeResolvedTo("done"); + await wait(); + expect(client.pending).toBe(0); + client.dispose(); + }); + + it("delegates configured wrapper state, operations, events, and scope binding", async () => { + const bind = jasmine.createSpy("bind"); + const dispose = jasmine.createSpy("dispose"); + const registration = createRegistration(); + const state: ng.ServiceWorkerUpdateState = { + checking: false, + waiting: false, + controllerChanged: false, + }; + const service = { + support: { supported: true }, + supported: true, + status: "idle", + controller: null, + registration, + registrationState: { registered: true }, + updateState: state, + register: jasmine.createSpy("register").and.resolveTo(registration), + ready: jasmine.createSpy("ready").and.resolveTo(registration), + update: jasmine.createSpy("update").and.resolveTo(registration), + unregister: jasmine.createSpy("unregister").and.resolveTo(true), + post: jasmine.createSpy("post").and.resolveTo(undefined), + onMessage: jasmine.createSpy("onMessage").and.returnValue(dispose), + onControllerChange: jasmine + .createSpy("onControllerChange") + .and.returnValue(dispose), + onUpdate: jasmine.createSpy("onUpdate").and.returnValue(dispose), + } as unknown as ng.ServiceWorkerService; + + Object.defineProperty(service, SCOPE_PROXY_BIND, { value: bind }); + + const wrapper = createConfiguredServiceWorkerService(service, "/app.js"); + + expect(wrapper.support).toBe(service.support); + expect(wrapper.supported).toBeTrue(); + expect(wrapper.status).toBe("idle"); + expect(wrapper.controller).toBeNull(); + expect(wrapper.registration).toBe(registration); + expect(wrapper.registrationState).toBe(service.registrationState); + expect(wrapper.updateState).toBe(state); + + await (wrapper.register as () => Promise)(); + await wrapper.register("/other.js", { scope: "/other/" }); + await wrapper.ready(); + await wrapper.update(); + await wrapper.unregister(); + await wrapper.post("message", [], "active"); + expect(wrapper.onMessage(() => undefined)).toBe(dispose); + expect(wrapper.onControllerChange(() => undefined)).toBe(dispose); + expect(wrapper.onUpdate(() => undefined)).toBe(dispose); + + const handler = createReactiveScope(); + + ( + wrapper as unknown as { + [SCOPE_PROXY_BIND](handler: ng.Scope, proxy: ng.Scope): void; + } + )[SCOPE_PROXY_BIND](handler, handler); + + expect(bind).toHaveBeenCalledWith(handler, handler); + expect(service.register).toHaveBeenCalledWith("/app.js", {}); + expect(service.register).toHaveBeenCalledWith("/other.js", { + scope: "/other/", + }); + }); + + it("auto-registers configured wrappers and reports failures", async () => { + const err = jasmine.createSpy("err"); + const service = { + register: jasmine.createSpy("register").and.rejectWith(new Error("fail")), + } as unknown as ng.ServiceWorkerService; + + createConfiguredServiceWorkerService( + service, + "/default.js", + { + autoRegister: true, + scriptUrl: "/configured.js", + }, + err as unknown as ng.ExceptionHandlerService, + ); + createConfiguredServiceWorkerService(service, "/unhandled.js", { + autoRegister: true, + }); + + await wait(); + + expect(service.register).toHaveBeenCalledWith("/configured.js", { + autoRegister: true, + scriptUrl: "/configured.js", + }); + expect(err).toHaveBeenCalledTimes(1); + }); + + it("distinguishes a missing navigator from a missing worker container", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: undefined, + }); + + try { + const service = createServiceWorkerService(undefined, { + log: createLog(), + err: createExceptionHandler(), + }); + + expect(service.support).toEqual({ + supported: false, + reason: "missing-navigator", + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "navigator", descriptor); + } + } + }); +}); diff --git a/src/services/service-worker/service-worker.test.ts b/src/services/service-worker/service-worker.test.ts new file mode 100644 index 000000000..768b10722 --- /dev/null +++ b/src/services/service-worker/service-worker.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "@playwright/test"; +import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; + +const TEST_URL = "src/services/service-worker/service-worker.html?random=false"; +const INTEGRATION_URL = + "src/services/service-worker/service-worker-integration.html?random=false"; +const DOCS_EXAMPLE_URL = + "docs/static/examples/service-worker/service-worker.html?random=false"; + +test("unit tests contain no errors", async ({ page }) => { + await expectNoJasmineFailures(page, TEST_URL); +}); + +test("registers a real service worker and exchanges messages", async ({ + page, +}) => { + await page.goto(INTEGRATION_URL); + const supported = await page.evaluate(() => { + const target = window as unknown as { + serviceWorkerIntegration: { supported(): boolean }; + }; + + return target.serviceWorkerIntegration.supported(); + }); + + test.skip(!supported, "Service workers are unavailable in this browser."); + + await page.evaluate(async () => { + const target = window as unknown as { + serviceWorkerIntegration: { cleanup(): Promise }; + }; + + await target.serviceWorkerIntegration.cleanup(); + }); + await page.goto(`${INTEGRATION_URL}&case=message`); + + const result = await page.evaluate(async () => { + const target = window as unknown as { + serviceWorkerIntegration: { + cleanup(): Promise; + registerAndExchange(testId: string): Promise<{ + controller: boolean; + message: { + kind: string; + payload: { ok: boolean }; + testId: string; + version: string; + }; + status: string; + }>; + }; + }; + const testId = `message-${String(Date.now())}`; + + try { + return await target.serviceWorkerIntegration.registerAndExchange(testId); + } finally { + await target.serviceWorkerIntegration.cleanup(); + } + }); + + expect(result.controller).toBe(true); + expect(result.status).toBe("ready"); + expect(result.message).toEqual({ + kind: "pong", + payload: { ok: true }, + testId: expect.stringMatching(/^message-/), + version: "message", + }); +}); + +test("detects an updated real service worker script", async ({ page }) => { + await page.goto(INTEGRATION_URL); + const supported = await page.evaluate(() => { + const target = window as unknown as { + serviceWorkerIntegration: { supported(): boolean }; + }; + + return target.serviceWorkerIntegration.supported(); + }); + + test.skip(!supported, "Service workers are unavailable in this browser."); + + await page.evaluate(async () => { + const target = window as unknown as { + serviceWorkerIntegration: { cleanup(): Promise }; + }; + + await target.serviceWorkerIntegration.cleanup(); + }); + await page.goto(`${INTEGRATION_URL}&case=update`); + + const result = await page.evaluate(async () => { + const target = window as unknown as { + serviceWorkerIntegration: { + cleanup(): Promise; + detectUpdate(testId: string): Promise<{ + observed: Array<{ + phase?: ServiceWorkerState; + scriptUrl: string; + waiting: boolean; + }>; + update: { + phase?: ServiceWorkerState; + scriptUrl: string; + waiting: boolean; + }; + }>; + }; + }; + const testId = `update-${String(Date.now())}`; + + try { + return await target.serviceWorkerIntegration.detectUpdate(testId); + } finally { + await target.serviceWorkerIntegration.cleanup(); + } + }); + + expect(result.observed.length).toBeGreaterThan(0); + expect(result.update.scriptUrl).toContain("version=two"); + expect(result.update.phase).toBeTruthy(); +}); + +test("docs service worker example registers, messages, and prompts updates", async ({ + page, +}) => { + await page.goto(DOCS_EXAMPLE_URL); + const supported = await page.evaluate(() => "serviceWorker" in navigator); + + test.skip(!supported, "Service workers are unavailable in this browser."); + + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + + await Promise.all( + registrations + .filter((registration) => + registration.scope.includes("/docs/static/examples/service-worker/"), + ) + .map((registration) => registration.unregister()), + ); + }); + await page.goto(`${DOCS_EXAMPLE_URL}&case=docs`); + + try { + await page.getByTestId("register").click(); + await expect(page.getByTestId("status")).toHaveText("ready"); + + await page.getByTestId("message-worker").click(); + await expect(page.getByTestId("message")).toHaveText("Worker 1 replied."); + + await page.getByTestId("stage-update").click(); + await expect(page.getByTestId("update")).toHaveText( + "Update is waiting. Activation is your choice.", + ); + + await page.getByTestId("activate-update").click(); + await expect(page.getByTestId("status")).toHaveText( + "Controller changed. Reload remains explicit.", + ); + } finally { + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + + await Promise.all( + registrations + .filter((registration) => + registration.scope.includes( + "/docs/static/examples/service-worker/", + ), + ) + .map((registration) => registration.unregister()), + ); + }); + } +}); diff --git a/src/services/service-worker/service-worker.ts b/src/services/service-worker/service-worker.ts new file mode 100644 index 000000000..f803476c6 --- /dev/null +++ b/src/services/service-worker/service-worker.ts @@ -0,0 +1,1199 @@ +import { + SCOPE_PROXY_BIND, + type Scope, + type ScopeProxyBindable, +} from "../../core/scope/scope.ts"; + +/** + * Stable service-worker support state exposed by `$serviceWorker`. + */ +export interface ServiceWorkerSupport { + /** True when the current browser exposes `navigator.serviceWorker`. */ + supported: boolean; + + /** Stable reason for unsupported environments. */ + reason?: "missing-navigator" | "missing-service-worker"; +} + +/** + * Declarative defaults used when registering an application service worker. + * + * This config intentionally maps only browser registration options and safe + * observation policy. Activation, reload, cache strategy, push, and background + * sync remain explicit application or adapter policy. + */ +export interface ServiceWorkerConfig { + /** Default registration scope passed to `register(...)`. */ + scope?: string; + + /** Worker script type passed to `register(...)`. */ + type?: WorkerType; + + /** Browser update-cache policy passed to `register(...)`. */ + updateViaCache?: ServiceWorkerUpdateViaCache; + + /** Register automatically when the runtime service is created. */ + autoRegister?: boolean; + + /** Default script URL used by `autoRegister`. */ + scriptUrl?: string | URL; + + /** Check for an updated worker after registration succeeds. */ + checkForUpdatesOnRegister?: boolean; +} + +/** + * Template-friendly snapshot of the current registration. + */ +export interface ServiceWorkerRegistrationState { + /** True when a registration is currently known by the service. */ + registered: boolean; + + /** Registration scope, when available. */ + scope?: string; + + /** Update cache policy reported by the browser registration. */ + updateViaCache?: ServiceWorkerUpdateViaCache; + + /** State of the installing worker, when present. */ + installing?: ServiceWorkerState; + + /** State of the waiting worker, when present. */ + waiting?: ServiceWorkerState; + + /** State of the active worker, when present. */ + active?: ServiceWorkerState; +} + +/** + * Template-friendly snapshot of update-related service-worker state. + */ +export interface ServiceWorkerUpdateState { + /** True while an explicit update check is in flight. */ + checking: boolean; + + /** True when a waiting worker has been discovered. */ + waiting: boolean; + + /** True when the active worker changed during the current page lifetime. */ + controllerChanged: boolean; + + /** Last successful update-check time in epoch milliseconds. */ + lastCheckedAt?: number; + + /** Latest observed service worker lifecycle phase. */ + phase?: ServiceWorkerState; + + /** Worker associated with the latest update event. */ + worker?: ServiceWorker; + + /** Registration associated with the latest update event. */ + registration?: ServiceWorkerRegistration; + + /** Stable failure code from the last update-related operation. */ + errorCode?: "unsupported" | "no-registration" | "update-failed"; + + /** Native error preserved for diagnostics. */ + error?: unknown; +} + +/** + * Message event normalized by `$serviceWorker`. + */ +export interface ServiceWorkerMessageEvent { + /** Message payload from the native service worker event. */ + data: TData; + + /** Native event for callers that need browser-specific fields. */ + event: MessageEvent; + + /** Native source that sent the message, when supplied by the browser. */ + source?: ServiceWorker | MessageEventSource | null; +} + +/** + * Explicit message target for `$serviceWorker.post(...)`. + */ +export type ServiceWorkerMessageTarget = + | "controller" + | "active" + | "waiting" + | "installing"; + +/** + * Request envelope sent by {@link ServiceWorkerMessageClient}. + */ +export interface ServiceWorkerMessageRequest { + /** Stable protocol discriminator for request messages. */ + type: string; + + /** Correlation id used to match a later response. */ + id: string; + + /** Application payload delivered to the service worker. */ + payload: TPayload; +} + +/** + * Response envelope consumed by {@link ServiceWorkerMessageClient}. + */ +export interface ServiceWorkerMessageResponse { + /** Stable protocol discriminator for response messages. */ + type: string; + + /** Correlation id copied from the request. */ + id: string; + + /** True for successful responses; false rejects the pending request. */ + ok?: boolean; + + /** Successful response payload. */ + data?: TData; + + /** Error payload preserved when `ok` is false. */ + error?: unknown; +} + +/** + * Configuration for {@link ServiceWorkerMessageClient}. + */ +export interface ServiceWorkerMessageClientOptions { + /** Request envelope type. */ + requestType?: string; + + /** Response envelope type. */ + responseType?: string; + + /** Timeout in milliseconds for each request. */ + timeout?: number; + + /** Creates request ids. Defaults to a monotonically increasing id. */ + createId?: () => string; + + /** Default worker target used by `request(...)`. */ + target?: ServiceWorkerMessageTarget; +} + +/** + * Per-request overrides for {@link ServiceWorkerMessageClient.request}. + */ +export interface ServiceWorkerMessageClientRequestOptions { + /** Transferable objects sent with `postMessage(...)`. */ + transfer?: Transferable[]; + + /** Request timeout in milliseconds. */ + timeout?: number; + + /** Worker target for this request. */ + target?: ServiceWorkerMessageTarget; +} + +export type ServiceWorkerMessageClientErrorCode = + | "disposed" + | "post-failed" + | "response-error" + | "timeout"; + +/** + * Injectable service-worker lifecycle and messaging facade. + */ +export interface ServiceWorkerService { + /** Stable support state for the current browser. */ + readonly support: ServiceWorkerSupport; + + /** Convenience support flag for templates and guards. */ + readonly supported: boolean; + + /** Template-facing lifecycle status for the latest service-worker operation. */ + readonly status: + | "unsupported" + | "idle" + | "registering" + | "registered" + | "ready" + | "updating" + | "unregistered" + | "error"; + + /** Current native controller, if the page is controlled. */ + readonly controller: ServiceWorker | null; + + /** Latest known native registration. */ + readonly registration: ServiceWorkerRegistration | null; + + /** Template-friendly registration snapshot. */ + readonly registrationState: ServiceWorkerRegistrationState; + + /** Template-friendly update snapshot. */ + readonly updateState: ServiceWorkerUpdateState; + + /** Register an application-owned service worker script. */ + register( + scriptUrl: string | URL, + options?: ServiceWorkerConfig, + ): Promise; + + /** Resolve when the browser reports an active ready registration. */ + ready(): Promise; + + /** Ask the latest known registration to check for an updated worker. */ + update(): Promise; + + /** Unregister the latest known registration. */ + unregister(): Promise; + + /** Send a message to the current controller or an explicit worker target. */ + post( + message: unknown, + transfer?: Transferable[], + target?: ServiceWorkerMessageTarget, + ): Promise; + + /** Subscribe to messages from the service worker container. */ + onMessage( + callback: (event: ServiceWorkerMessageEvent) => void, + ): () => void; + + /** Subscribe to controller-change notifications. */ + onControllerChange( + callback: (controller: ServiceWorker | null) => void, + ): () => void; + + /** Subscribe to update-state notifications. */ + onUpdate(callback: (state: ServiceWorkerUpdateState) => void): () => void; +} + +type ServiceWorkerFailureCode = + | "unsupported" + | "no-controller" + | "no-registration" + | "register-failed" + | "ready-failed" + | "update-failed" + | "unregister-failed"; + +interface ServiceWorkerFactoryOptions { + log: ng.LogService; + err: ng.ExceptionHandlerService; +} + +type ServiceWorkerStatus = ServiceWorkerService["status"]; + +type ServiceWorkerTarget = ServiceWorkerService & ScopeProxyBindable; + +interface ServiceWorkerBinding { + _handler: Scope; +} + +interface PendingServiceWorkerMessageRequest { + _reject: (reason: unknown) => void; + _resolve: (value: unknown) => void; + _timer: ReturnType; +} + +type ServiceWorkerContainerEventType = "message" | "controllerchange"; + +type ServiceWorkerContainerEventListener = (event: Event) => void; + +/** + * Stable rejected error used by the service-worker skeleton. + * + * This class is exported for tests and internal adapters. It is intentionally + * not exported through the public docs surface until the failure contract is + * finalized by the runtime slices. + */ +export class ServiceWorkerError extends Error { + constructor( + public readonly code: ServiceWorkerFailureCode, + message: string, + public readonly nativeError?: unknown, + ) { + super(message); + this.name = "ServiceWorkerError"; + } +} + +/** + * Stable error used by {@link ServiceWorkerMessageClient}. + */ +export class ServiceWorkerMessageClientError extends Error { + constructor( + public readonly code: ServiceWorkerMessageClientErrorCode, + message: string, + public readonly detail?: unknown, + ) { + super(message); + this.name = "ServiceWorkerMessageClientError"; + } +} + +function createUnsupportedError(): ServiceWorkerError { + return new ServiceWorkerError( + "unsupported", + "Service workers are not supported in this environment.", + ); +} + +function createNoRegistrationError(): ServiceWorkerError { + return new ServiceWorkerError( + "no-registration", + "No service worker registration is available.", + ); +} + +function createNativeOperationError( + code: ServiceWorkerFailureCode, + message: string, + nativeError: unknown, +): ServiceWorkerError { + return new ServiceWorkerError(code, message, nativeError); +} + +function resolveUnsupported(): Promise { + return Promise.reject(createUnsupportedError()); +} + +function createNoControllerError(): ServiceWorkerError { + return new ServiceWorkerError( + "no-controller", + "No active service worker controller is available.", + ); +} + +function createSupport( + container: ServiceWorkerContainer | undefined | null, +): ServiceWorkerSupport { + if (container) { + return { supported: true }; + } + + if (typeof navigator === "undefined") { + return { supported: false, reason: "missing-navigator" }; + } + + return { supported: false, reason: "missing-service-worker" }; +} + +const defaultMessageClientRequestType = "angular-ts:service-worker:request"; +const defaultMessageClientResponseType = "angular-ts:service-worker:response"; +const defaultMessageClientTimeout = 30_000; + +/** + * Request/response adapter over `$serviceWorker` messages. + * + * The adapter only correlates messages. It does not queue requests, retry + * failed posts, activate waiting workers, or define a service-worker-side + * implementation. + */ +export class ServiceWorkerMessageClient { + private _disposed = false; + private _nextId = 0; + private readonly _pending = new Map< + string, + PendingServiceWorkerMessageRequest + >(); + private readonly _requestType: string; + private readonly _responseType: string; + private readonly _timeout: number; + private readonly _target?: ServiceWorkerMessageTarget; + private readonly _createId: () => string; + private readonly _disposeMessages: () => void; + + /** + * @param service - Service-worker facade used for messaging. + * @param options - Protocol, timeout, id, and default target options. + */ + constructor( + private readonly service: ServiceWorkerService, + options: ServiceWorkerMessageClientOptions = {}, + ) { + this._requestType = options.requestType ?? defaultMessageClientRequestType; + this._responseType = + options.responseType ?? defaultMessageClientResponseType; + this._timeout = options.timeout ?? defaultMessageClientTimeout; + this._target = options.target; + this._createId = + options.createId ?? + (() => { + this._nextId++; + + return `sw:${String(this._nextId)}`; + }); + this._disposeMessages = service.onMessage((event) => { + this._handleMessage(event.data); + }); + } + + /** Number of unresolved requests currently waiting for a response. */ + get pending(): number { + return this._pending.size; + } + + /** True after {@link dispose} has been called. */ + get disposed(): boolean { + return this._disposed; + } + + /** + * Send a correlated request and resolve with the matching response payload. + * + * @param payload - Application request payload. + * @param options - Per-request target, transfer, and timeout overrides. + */ + request( + payload: unknown, + options: ServiceWorkerMessageClientRequestOptions = {}, + ): Promise { + if (this._disposed) { + return Promise.reject( + new ServiceWorkerMessageClientError( + "disposed", + "Service worker message client is disposed.", + ), + ); + } + + const id = this._createId(); + const timeout = options.timeout ?? this._timeout; + const message: ServiceWorkerMessageRequest = { + type: this._requestType, + id, + payload, + }; + + return new Promise((resolve, reject) => { + const timer = globalThis.setTimeout(() => { + this._pending.delete(id); + reject( + new ServiceWorkerMessageClientError( + "timeout", + `Service worker message request '${id}' timed out.`, + { id, message }, + ), + ); + }, timeout); + + this._pending.set(id, { + _resolve: resolve as (value: unknown) => void, + _reject: reject, + _timer: timer, + }); + + this.service + .post(message, options.transfer, options.target ?? this._target) + .catch((error: unknown) => { + this._rejectPending( + id, + new ServiceWorkerMessageClientError( + "post-failed", + `Service worker message request '${id}' could not be posted.`, + error, + ), + ); + }); + }); + } + + /** + * Stop listening for responses and reject all pending requests. + */ + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + this._disposeMessages(); + + for (const [id, pending] of this._pending) { + globalThis.clearTimeout(pending._timer); + pending._reject( + new ServiceWorkerMessageClientError( + "disposed", + `Service worker message request '${id}' was disposed.`, + { id }, + ), + ); + } + + this._pending.clear(); + } + + private _handleMessage(message: unknown): void { + if (!message || typeof message !== "object") { + return; + } + + const response = message as Partial; + + if ( + response.type !== this._responseType || + typeof response.id !== "string" + ) { + return; + } + + const pending = this._pending.get(response.id); + + if (!pending) { + return; + } + + this._pending.delete(response.id); + globalThis.clearTimeout(pending._timer); + + if (response.ok === false) { + pending._reject( + new ServiceWorkerMessageClientError( + "response-error", + `Service worker message request '${response.id}' failed.`, + response.error, + ), + ); + + return; + } + + pending._resolve(response.data); + } + + private _rejectPending(id: string, error: ServiceWorkerMessageClientError) { + const pending = this._pending.get(id); + + if (!pending) { + return; + } + + this._pending.delete(id); + globalThis.clearTimeout(pending._timer); + pending._reject(error); + } +} + +const serviceWorkerServiceDisposers = new WeakMap< + ServiceWorkerService, + () => void +>(); + +/** @internal */ +export function destroyServiceWorkerService( + service: ServiceWorkerService, +): void { + const dispose = serviceWorkerServiceDisposers.get(service); + + if (!dispose) return; + + serviceWorkerServiceDisposers.delete(service); + dispose(); +} + +/** + * Create the injectable service-worker facade. + * + * The optional container parameter is the internal test seam. Passing `null` or + * `undefined` creates the same stable unsupported service object users get in + * browsers without `navigator.serviceWorker`. + */ +export function createServiceWorkerService( + container: ServiceWorkerContainer | undefined | null, + options: ServiceWorkerFactoryOptions, +): ServiceWorkerService { + const support = createSupport(container); + let status: ServiceWorkerStatus = support.supported ? "idle" : "unsupported"; + let controller = container?.controller ?? null; + let registration: ServiceWorkerRegistration | null = null; + let disposeRegistrationUpdateListener: (() => void) | undefined; + let disposeWorkerStateListener: (() => void) | undefined; + let destroyed = false; + const updateCallbacks: Array<(state: ServiceWorkerUpdateState) => void> = []; + const controllerCallbacks: Array<(controller: ServiceWorker | null) => void> = + []; + const bindings = new Map(); + const containerListenerDisposers = new Set<() => void>(); + const registrationState: ServiceWorkerRegistrationState = { + registered: false, + }; + const updateState: ServiceWorkerUpdateState = { + checking: false, + waiting: false, + controllerChanged: false, + }; + + const getServiceWatchKeys = (): string[] => [ + "supported", + "status", + "controller", + "registration", + "registrationState", + "updateState", + ]; + + const scheduleServiceBindings = (): void => { + if (destroyed) return; + + for (const [scopeId, binding] of bindings) { + if (binding._handler._destroyed) { + bindings.delete(scopeId); + + continue; + } + + binding._handler._scheduleWatchKeys(getServiceWatchKeys()); + binding._handler._checkListenersForAllKeys(registrationState as never); + binding._handler._checkListenersForAllKeys(updateState as never); + } + }; + + const setStatus = (nextStatus: ServiceWorkerStatus): void => { + status = nextStatus; + }; + + const updateRegistrationState = ( + nextRegistration: ServiceWorkerRegistration | null, + ): void => { + registration = nextRegistration; + registrationState.registered = Boolean(nextRegistration); + + if (!nextRegistration) { + delete registrationState.scope; + delete registrationState.updateViaCache; + delete registrationState.installing; + delete registrationState.waiting; + delete registrationState.active; + updateState.waiting = false; + delete updateState.phase; + delete updateState.worker; + delete updateState.registration; + scheduleServiceBindings(); + return; + } + + delete updateState.errorCode; + delete updateState.error; + + registrationState.scope = nextRegistration.scope; + registrationState.updateViaCache = nextRegistration.updateViaCache; + + const installing = nextRegistration.installing?.state; + const waiting = nextRegistration.waiting?.state; + const active = nextRegistration.active?.state; + + if (installing) { + registrationState.installing = installing; + } else { + delete registrationState.installing; + } + + if (waiting) { + registrationState.waiting = waiting; + } else { + delete registrationState.waiting; + } + + if (active) { + registrationState.active = active; + } else { + delete registrationState.active; + } + + updateState.waiting = Boolean(nextRegistration.waiting); + scheduleServiceBindings(); + }; + + const notifyUpdateCallbacks = (): void => { + updateCallbacks.slice().forEach((callback) => { + try { + callback(updateState); + } catch (error) { + options.err(error); + } + }); + }; + + const emitUpdateState = ( + worker: ServiceWorker, + nextRegistration: ServiceWorkerRegistration, + ): void => { + updateRegistrationState(nextRegistration); + updateState.phase = worker.state; + updateState.worker = worker; + updateState.registration = nextRegistration; + updateState.waiting = Boolean(nextRegistration.waiting); + scheduleServiceBindings(); + notifyUpdateCallbacks(); + }; + + const watchUpdateWorker = ( + worker: ServiceWorker, + nextRegistration: ServiceWorkerRegistration, + ): void => { + disposeWorkerStateListener?.(); + disposeWorkerStateListener = undefined; + + if (destroyed) return; + + const onStateChange = () => { + emitUpdateState(worker, nextRegistration); + }; + + worker.addEventListener("statechange", onStateChange); + disposeWorkerStateListener = () => { + worker.removeEventListener("statechange", onStateChange); + }; + + emitUpdateState(worker, nextRegistration); + }; + + const trackRegistrationUpdates = ( + nextRegistration: ServiceWorkerRegistration | null, + ): void => { + disposeRegistrationUpdateListener?.(); + disposeRegistrationUpdateListener = undefined; + + if (destroyed || !nextRegistration) { + disposeWorkerStateListener?.(); + disposeWorkerStateListener = undefined; + return; + } + + const onUpdateFound = () => { + const worker = + nextRegistration.installing ?? + nextRegistration.waiting ?? + nextRegistration.active; + + if (worker) { + watchUpdateWorker(worker, nextRegistration); + } + }; + + nextRegistration.addEventListener("updatefound", onUpdateFound); + disposeRegistrationUpdateListener = () => { + nextRegistration.removeEventListener("updatefound", onUpdateFound); + }; + }; + + const registerOptions = ( + config: ServiceWorkerConfig | undefined, + ): RegistrationOptions | undefined => { + if (!config) { + return undefined; + } + + const normalized: RegistrationOptions = {}; + + if (config.scope !== undefined) { + normalized.scope = config.scope; + } + + if (config.type !== undefined) { + normalized.type = config.type; + } + + if (config.updateViaCache !== undefined) { + normalized.updateViaCache = config.updateViaCache; + } + + return normalized; + }; + + const listen = ( + type: ServiceWorkerContainerEventType, + listener: ServiceWorkerContainerEventListener, + ): (() => void) => { + if ( + destroyed || + !container || + typeof container.addEventListener !== "function" || + typeof container.removeEventListener !== "function" + ) { + return () => undefined; + } + + container.addEventListener(type, listener); + + let disposed = false; + const dispose = () => { + if (disposed) return; + + disposed = true; + container.removeEventListener(type, listener); + containerListenerDisposers.delete(dispose); + }; + + containerListenerDisposers.add(dispose); + + return dispose; + }; + + const disposeControllerChangeListener = listen("controllerchange", () => { + controller = container?.controller ?? null; + updateState.controllerChanged = true; + scheduleServiceBindings(); + + controllerCallbacks.slice().forEach((callback) => { + try { + callback(controller); + } catch (error) { + options.err(error); + } + }); + }); + + const getMessageTarget = ( + target: ServiceWorkerMessageTarget = "controller", + ): ServiceWorker | null => { + switch (target) { + case "active": + return registration?.active ?? null; + case "installing": + return registration?.installing ?? null; + case "waiting": + return registration?.waiting ?? null; + case "controller": + return controller; + } + }; + + const service: ServiceWorkerTarget = { + support, + supported: support.supported, + get status() { + return status; + }, + get controller() { + return controller; + }, + get registration() { + return registration; + }, + registrationState, + updateState, + + async register(scriptUrl: string | URL, config?: ServiceWorkerConfig) { + if (!container) { + return resolveUnsupported(); + } + + setStatus("registering"); + scheduleServiceBindings(); + + try { + const nextRegistration = await container.register( + scriptUrl, + registerOptions(config), + ); + + updateRegistrationState(nextRegistration); + trackRegistrationUpdates(nextRegistration); + setStatus("registered"); + scheduleServiceBindings(); + + if (config?.checkForUpdatesOnRegister) { + await service.update(); + } + + return nextRegistration; + } catch (error) { + setStatus("error"); + scheduleServiceBindings(); + throw createNativeOperationError( + "register-failed", + "Service worker registration failed.", + error, + ); + } + }, + + async ready() { + if (!container) { + return resolveUnsupported(); + } + + try { + const readyRegistration = await container.ready; + + updateRegistrationState(readyRegistration); + trackRegistrationUpdates(readyRegistration); + setStatus("ready"); + scheduleServiceBindings(); + + return readyRegistration; + } catch (error) { + setStatus("error"); + scheduleServiceBindings(); + throw createNativeOperationError( + "ready-failed", + "Service worker ready promise failed.", + error, + ); + } + }, + + async update() { + if (!container) { + return resolveUnsupported(); + } + + if (!registration) { + updateState.errorCode = "no-registration"; + setStatus("error"); + scheduleServiceBindings(); + throw createNoRegistrationError(); + } + + updateState.checking = true; + delete updateState.errorCode; + delete updateState.error; + setStatus("updating"); + scheduleServiceBindings(); + + try { + const nextRegistration = await registration.update(); + + updateState.lastCheckedAt = Date.now(); + updateRegistrationState(nextRegistration); + trackRegistrationUpdates(nextRegistration); + setStatus("registered"); + scheduleServiceBindings(); + + return nextRegistration; + } catch (error) { + updateState.errorCode = "update-failed"; + updateState.error = error; + setStatus("error"); + scheduleServiceBindings(); + throw createNativeOperationError( + "update-failed", + "Service worker update check failed.", + error, + ); + } finally { + updateState.checking = false; + scheduleServiceBindings(); + } + }, + + async unregister() { + if (!container) { + return resolveUnsupported(); + } + + if (!registration) { + setStatus("error"); + scheduleServiceBindings(); + throw createNoRegistrationError(); + } + + try { + const didUnregister = await registration.unregister(); + + if (didUnregister) { + updateRegistrationState(null); + trackRegistrationUpdates(null); + setStatus("unregistered"); + scheduleServiceBindings(); + } + + return didUnregister; + } catch (error) { + setStatus("error"); + scheduleServiceBindings(); + throw createNativeOperationError( + "unregister-failed", + "Service worker unregister failed.", + error, + ); + } + }, + + async post( + message: unknown, + transfer?: Transferable[], + target?: ServiceWorkerMessageTarget, + ) { + if (!container) { + return resolveUnsupported(); + } + + const worker = getMessageTarget(target); + + if (!worker) { + throw createNoControllerError(); + } + + worker.postMessage(message, transfer ?? []); + }, + + onMessage( + callback: (event: ServiceWorkerMessageEvent) => void, + ) { + return listen("message", (event) => { + const messageEvent = event as MessageEvent; + + try { + callback({ + data: messageEvent.data, + event: messageEvent, + source: messageEvent.source, + }); + } catch (error) { + options.err(error); + } + }); + }, + + onControllerChange(callback: (controller: ServiceWorker | null) => void) { + if (destroyed || !container) { + return () => undefined; + } + + controllerCallbacks.push(callback); + + return () => { + const index = controllerCallbacks.indexOf(callback); + + if (index !== -1) { + controllerCallbacks.splice(index, 1); + } + }; + }, + + onUpdate(callback: (state: ServiceWorkerUpdateState) => void) { + if (destroyed) { + return () => undefined; + } + + updateCallbacks.push(callback); + + return () => { + const index = updateCallbacks.indexOf(callback); + + if (index !== -1) { + updateCallbacks.splice(index, 1); + } + }; + }, + }; + + Object.defineProperty(service, SCOPE_PROXY_BIND, { + value(handler: Scope) { + if (destroyed) return; + + let binding = bindings.get(handler.$id); + + if (!binding) { + binding = { + _handler: handler, + }; + bindings.set(handler.$id, binding); + } + }, + }); + + serviceWorkerServiceDisposers.set(service, () => { + destroyed = true; + disposeWorkerStateListener?.(); + disposeWorkerStateListener = undefined; + disposeRegistrationUpdateListener?.(); + disposeRegistrationUpdateListener = undefined; + + for (const dispose of Array.from(containerListenerDisposers)) dispose(); + + controllerCallbacks.length = 0; + updateCallbacks.length = 0; + bindings.clear(); + }); + + if (!support.supported) { + options.log.warn("Service workers are not supported in this environment."); + disposeControllerChangeListener(); + } + + return service; +} + +/** + * Create a module-named service worker wrapper with default registration + * script/config while preserving the singleton browser container state. + * + * The wrapper delegates state and events to `$serviceWorker`. It exists so + * modules can expose an injectable named service worker without creating a + * second service-worker container abstraction. + */ +export function createConfiguredServiceWorkerService( + service: ServiceWorkerService, + scriptUrl: string | URL, + config: ServiceWorkerConfig = {}, + handleAutoRegisterError?: ng.ExceptionHandlerService, +): ServiceWorkerService { + const bindService = (service as ScopeProxyBindable)[SCOPE_PROXY_BIND]; + const wrapper: ServiceWorkerTarget = { + get support() { + return service.support; + }, + get supported() { + return service.supported; + }, + get status() { + return service.status; + }, + get controller() { + return service.controller; + }, + get registration() { + return service.registration; + }, + get registrationState() { + return service.registrationState; + }, + get updateState() { + return service.updateState; + }, + register(nextScriptUrl: string | URL = scriptUrl, options = config) { + return service.register(nextScriptUrl, { + ...config, + ...options, + }); + }, + ready() { + return service.ready(); + }, + update() { + return service.update(); + }, + unregister() { + return service.unregister(); + }, + post(message, transfer, target) { + return service.post(message, transfer, target); + }, + onMessage(callback) { + return service.onMessage(callback); + }, + onControllerChange(callback) { + return service.onControllerChange(callback); + }, + onUpdate(callback) { + return service.onUpdate(callback); + }, + }; + + if (bindService) { + Object.defineProperty(wrapper, SCOPE_PROXY_BIND, { + value(handler: Scope, proxy: ng.Scope) { + bindService.call(service, handler, proxy); + }, + }); + } + + if (config.autoRegister) { + void wrapper + .register(config.scriptUrl ?? scriptUrl, config) + .catch((error: unknown) => { + handleAutoRegisterError?.(error); + }); + } + + return wrapper; +} diff --git a/src/services/sse/sse.spec.ts b/src/services/sse/sse.spec.ts index a8c0b8200..e3e7ffd0a 100644 --- a/src/services/sse/sse.spec.ts +++ b/src/services/sse/sse.spec.ts @@ -3,41 +3,116 @@ import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; import { wait } from "../../shared/test-utils.ts"; +import { + createSseRuntimeConfiguration, + destroySseRuntimeConfiguration, +} from "./sse.ts"; describe("$sse", () => { - let sse, sseProvider, el, $compile, $scope; + let angular, sse, el, $compile, $scope; beforeEach(() => { el = document.getElementById("app"); el.innerHTML = ""; - const angular = new Angular(); + angular = new Angular(); - angular.module("default", []).config(($sseProvider) => { - sseProvider = $sseProvider; + angular.bootstrap(el, []).invoke((_$sse_, _$compile_, _$rootScope_) => { + sse = _$sse_; + $compile = _$compile_; + $scope = _$rootScope_; }); - - angular - .bootstrap(el, ["default"]) - .invoke((_$sse_, _$compile_, _$rootScope_) => { - sse = _$sse_; - $compile = _$compile_; - $scope = _$rootScope_; - }); }); afterEach(() => { + angular._composition.destroy(); dealoc(el); }); - it("should be available as provider", () => { - expect(sseProvider).toBeDefined(); - }); - it("should be available as a service", () => { expect(sse).toBeDefined(); }); + it("tears down runtime configuration idempotently", () => { + const configuration = createSseRuntimeConfiguration(); + const connection = { close: jasmine.createSpy("close") }; + configuration.connections.add(connection); + + destroySseRuntimeConfiguration(configuration); + destroySseRuntimeConfiguration(configuration); + + expect(connection.close).toHaveBeenCalledTimes(1); + }); + + it("should close owned connections when the runtime is destroyed", () => { + const RealEventSource = window.EventSource; + const sources = []; + + window.EventSource = function () { + this.closeCalls = 0; + this.addEventListener = () => undefined; + this.close = () => this.closeCalls++; + sources.push(this); + }; + + try { + sse("/mock/events", { heartbeatTimeout: 0 }); + angular._composition.destroy(); + + expect(sources[0].closeCalls).toBe(1); + expect(() => sse("/mock/events/after-destroy")).toThrowError( + "Cannot create an SSE connection after runtime teardown", + ); + } finally { + window.EventSource = RealEventSource; + } + }); + + it("should release explicitly closed connections from runtime ownership", () => { + const RealEventSource = window.EventSource; + const sources = []; + + window.EventSource = function () { + this.closeCalls = 0; + this.addEventListener = () => undefined; + this.close = () => this.closeCalls++; + sources.push(this); + }; + + try { + const source = sse("/mock/events", { heartbeatTimeout: 0 }); + + source.close(); + angular._composition.destroy(); + + expect(sources[0].closeCalls).toBe(1); + } finally { + window.EventSource = RealEventSource; + } + }); + + it("allows callers to request an explicit reconnect", () => { + const RealEventSource = window.EventSource; + const sources = []; + + window.EventSource = function () { + this.addEventListener = () => undefined; + this.close = () => undefined; + sources.push(this); + }; + + try { + const source = sse("/mock/events", { heartbeatTimeout: 0 }); + + source.reconnect(); + + expect(sources.length).toBe(2); + source.close(); + } finally { + window.EventSource = RealEventSource; + } + }); + it("should call onOpen when connection opens", async () => { let opened = false; @@ -127,19 +202,31 @@ describe("$sse", () => { }); it("should build URL with query params", () => { - const fn = sseProvider.$get[1](); + const RealEventSource = window.EventSource; + let builtUrl = ""; + + window.EventSource = function (url) { + builtUrl = url; + this.addEventListener = () => undefined; + this.close = () => undefined; + }; + + try { + const source = sse("/mock/events", { params: { a: 1, b: "x" } }); - const built = fn("/mock/events", { params: { a: 1, b: "x" } }); + expect(builtUrl).toContain("a=1"); + expect(builtUrl).toContain("b=x"); + source.close(); - // The SseProvider returns a connection object, not the raw EventSource, - // so we only verify that the URL builder works by checking the private method indirectly. - const testUrl = sseProvider["#buildUrl"] - ? sseProvider["#buildUrl"]("/mock/events", { a: 1, b: "x" }) - : "/mock/events?a=1&b=x"; + const querySource = sse("/mock/events?existing=true", { + params: { page: 2 }, + }); - expect(testUrl.indexOf("a=1") > -1).toBe(true); - expect(testUrl.indexOf("b=x") > -1).toBe(true); - built.close(); + expect(builtUrl).toContain("existing=true&page=2"); + querySource.close(); + } finally { + window.EventSource = RealEventSource; + } }); it("serializes all supported SSE query parameter types", async () => { @@ -238,11 +325,13 @@ describe("$sse", () => { window.EventSource = MockEventSource; const source = sse("/mock/events", { - heartbeatTimeout: 50, + heartbeatTimeout: 20, + maxRetries: 1, + retryDelay: 5, onReconnect: () => reconnects++, }); - await wait(1000); + await wait(50); // after one open + one reconnect, there should be ≥ 2 EventSource instances expect(instanceCount).toBeGreaterThanOrEqual(2); @@ -252,6 +341,101 @@ describe("$sse", () => { window.EventSource = RealEventSource; }); + it("should apply app configured defaults when opening runtime connections", async () => { + const configuredEl = document.createElement("div"); + const configuredAngular = new Angular(); + const RealEventSource = window.EventSource; + const openedSources = []; + const received = []; + let configuredSse; + + document.body.appendChild(configuredEl); + + window.EventSource = function (url, options) { + this.url = url; + this.options = options; + this.listeners = {}; + this.addEventListener = (type, fn) => { + this.listeners[type] = fn; + }; + this.close = () => { + /* empty */ + }; + openedSources.push(this); + setTimeout(() => this.listeners.open?.({ type: "open" }), 0); + }; + + try { + configuredAngular.module("configuredSseDefaults", []).config({ + $sse: { + defaults: { + heartbeatTimeout: 0, + params: { + configured: true, + }, + retryDelay: 5, + transformMessage(data) { + return { configured: data }; + }, + withCredentials: true, + }, + }, + }); + + configuredAngular + .bootstrap(configuredEl, ["configuredSseDefaults"]) + .invoke((_$sse_) => { + configuredSse = _$sse_; + }); + + const source = configuredSse("/configured/events", { + onMessage: (message) => received.push(message), + }); + + await wait(10); + + openedSources[0].listeners.message({ + type: "message", + data: "payload", + }); + + expect(openedSources[0].url).toContain("/configured/events?"); + expect(openedSources[0].url).toContain("configured=true"); + expect(openedSources[0].options).toEqual({ withCredentials: true }); + expect(received).toEqual([{ configured: "payload" }]); + + const localSource = configuredSse("/local/events", { + params: { + local: "yes", + }, + transformMessage(data) { + return { local: data }; + }, + withCredentials: false, + onMessage: (message) => received.push(message), + }); + + await wait(10); + + openedSources[1].listeners.message({ + type: "message", + data: "override", + }); + + expect(openedSources[1].url).toContain("local=yes"); + expect(openedSources[1].url).not.toContain("configured=true"); + expect(openedSources[1].options).toEqual({ withCredentials: false }); + expect(received[1]).toEqual({ local: "override" }); + + source.close(); + localSource.close(); + } finally { + configuredAngular._composition.destroy(); + window.EventSource = RealEventSource; + dealoc(configuredEl); + } + }); + it("should close the connection cleanly", async () => { let messageCount = 0; diff --git a/src/services/sse/sse.ts b/src/services/sse/sse.ts index e83fff688..86f61a4ab 100644 --- a/src/services/sse/sse.ts +++ b/src/services/sse/sse.ts @@ -1,4 +1,3 @@ -import { _log } from "../../injection-tokens.ts"; import { entries } from "../../shared/utils.ts"; import { ConnectionManager, @@ -15,9 +14,6 @@ export interface SseConfig extends ConnectionConfig { /** Optional query parameters appended to the URL */ params?: Record; - - /** Custom headers (EventSource doesn't natively support headers) */ - headers?: Record; } /** @@ -33,7 +29,7 @@ export interface SseConnection { * @remarks * Any previous event listeners are preserved; reconnects use the original configuration. */ - connect(): void; + reconnect(): void; } /** @@ -45,16 +41,17 @@ export interface SseConnection { */ export type SseService = (url: string, config?: SseConfig) => SseConnection; -export class SseProvider { - defaults: ng.SseConfig; - /** @internal */ - _$log!: LogService; +/** @internal */ +export interface SseRuntimeConfiguration { + defaults: SseConfig; + readonly connections: Set; + destroyed: boolean; +} - /** - * Creates the SSE provider with default reconnect and message parsing behavior. - */ - constructor() { - this.defaults = { +/** @internal */ +export function createSseRuntimeConfiguration(): SseRuntimeConfiguration { + return { + defaults: { retryDelay: 1000, maxRetries: Infinity, heartbeatTimeout: 15000, @@ -65,60 +62,99 @@ export class SseProvider { return data; } }, + }, + connections: new Set(), + destroyed: false, + }; +} + +/** @internal */ +export function applySseConfiguration( + configuration: SseRuntimeConfiguration, + config: { defaults?: SseConfig }, +): void { + if (config.defaults !== undefined) { + configuration.defaults = { + ...configuration.defaults, + ...config.defaults, }; } +} - /** - * Returns the `$sse` connection factory bound to the configured defaults. - */ - $get = [ - _log, - (log: ng.LogService): SseService => { - this._$log = log; - - return (url: string, config: SseConfig = {}): SseConnection => { - const mergedConfig = { ...this.defaults, ...config }; - - const finalUrl = SseProvider._buildUrl(url, mergedConfig.params); - - return new ConnectionManager( - () => - new EventSource(finalUrl, { - withCredentials: !!mergedConfig.withCredentials, - }), - { - ...mergedConfig, - onMessage: (data: unknown, event: Event) => { - // Cast Event -> MessageEvent safely - mergedConfig.onMessage?.(data, event as MessageEvent); - }, - }, - this._$log, - ); - }; - }, - ]; +/** @internal */ +export function destroySseRuntimeConfiguration( + configuration: SseRuntimeConfiguration, +): void { + if (configuration.destroyed) return; - /** - * Builds a URL with serialized query parameters. - */ - /** @internal */ - private static _buildUrl( - url: string, - params?: Record, - ): string { - if (!params) return url; - const query = entries(params) - .map( - ([k, v]) => - `${encodeURIComponent(k)}=${encodeURIComponent( - serializeQueryValue(v), - )}`, - ) - .join("&"); - - return url + (url.includes("?") ? "&" : "?") + query; - } + configuration.destroyed = true; + + for (const connection of configuration.connections) connection.close(); + + configuration.connections.clear(); +} + +/** @internal */ +export function createSseService( + log: LogService, + configuration: SseRuntimeConfiguration, + getEventSourceConstructor: () => typeof EventSource, +): SseService { + return (url: string, config: SseConfig = {}): SseConnection => { + if (configuration.destroyed) { + throw new Error("Cannot create an SSE connection after runtime teardown"); + } + + const mergedConfig = { ...configuration.defaults, ...config }; + const finalUrl = buildUrl(url, mergedConfig.params); + const manager = new ConnectionManager( + () => { + const EventSourceConstructor = getEventSourceConstructor(); + + return new EventSourceConstructor(finalUrl, { + withCredentials: !!mergedConfig.withCredentials, + }); + }, + { + ...mergedConfig, + onMessage: (data: unknown, event: Event) => { + mergedConfig.onMessage?.(data, event as MessageEvent); + }, + }, + log, + ); + let closed = false; + const connection: SseConnection = { + reconnect() { + manager.reconnect(); + }, + close() { + if (closed) return; + + closed = true; + configuration.connections.delete(connection); + manager.close(); + }, + }; + + configuration.connections.add(connection); + + return connection; + }; +} + +function buildUrl(url: string, params?: Record): string { + if (!params) return url; + const query = entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + serializeQueryValue(value), + )}`, + ) + .join("&"); + + return url + (url.includes("?") ? "&" : "?") + query; } function serializeQueryValue(value: unknown): string { diff --git a/src/services/stream/readable-stream.ts b/src/services/stream/readable-stream.ts index 18cec1b28..6822655ee 100644 --- a/src/services/stream/readable-stream.ts +++ b/src/services/stream/readable-stream.ts @@ -63,15 +63,16 @@ export interface StreamService { ): Promise; } -export class StreamProvider { - $get = (): StreamService => ({ +/** @internal Creates the dependency-free `$stream` service. */ +export function createStreamService(): StreamService { + return { isReadableStream, consumeText, readText, readLines, consumeJsonLines, readJsonLines, - }); + }; } function isReadableStream(value: unknown): value is ReadableStream { diff --git a/src/services/template-cache/template-cache.spec.ts b/src/services/template-cache/template-cache.spec.ts index e00b12130..fc60d7a78 100644 --- a/src/services/template-cache/template-cache.spec.ts +++ b/src/services/template-cache/template-cache.spec.ts @@ -5,7 +5,7 @@ import { wait } from "../../shared/test-utils.ts"; describe("$templateCache", () => { let templateCache: any, - templateCacheProvider: any, + configuredCache: any, el: any, $compile: any, $scope: any; @@ -15,9 +15,11 @@ describe("$templateCache", () => { el.innerHTML = ""; const angular = new Angular(); - angular.module("default", []).config(($templateCacheProvider) => { - templateCacheProvider = $templateCacheProvider; - templateCacheProvider.cache.set("test", "hello"); + configuredCache = new Map([["test", "hello"]]); + angular.module("default", []).config({ + $templateCache: { + cache: configuredCache, + }, }); angular .bootstrap(el, ["default"]) @@ -32,13 +34,9 @@ describe("$templateCache", () => { dealoc(el); }); - it("should be available as provider", () => { - expect(templateCacheProvider).toBeDefined(); - }); - it("should be available as a service", () => { expect(templateCache).toBeDefined(); - expect(templateCache).toEqual(templateCacheProvider.cache); + expect(templateCache).toBe(configuredCache); expect(templateCache instanceof Map).toBeTrue(); expect(templateCache.get("test")).toEqual("hello"); }); @@ -69,13 +67,13 @@ describe("$templateCache", () => { it("can be swapped for localStorage", async () => { dealoc(el); window.angular = new Angular(); - window.angular - .module("customStorage", []) - .config(($templateCacheProvider) => { - templateCacheProvider = $templateCacheProvider; - templateCacheProvider.cache = new LocalStorageMap(); - templateCacheProvider.cache.set("test", "hello"); - }); + configuredCache = new LocalStorageMap(); + configuredCache.set("test", "hello"); + window.angular.module("customStorage", []).config({ + $templateCache: { + cache: configuredCache, + }, + }); window.angular .bootstrap(el, ["customStorage"]) .invoke((_$templateCache_: any, _$compile_: any, _$rootScope_: any) => { diff --git a/src/services/template-cache/template-cache.ts b/src/services/template-cache/template-cache.ts index adf982fe7..429074629 100644 --- a/src/services/template-cache/template-cache.ts +++ b/src/services/template-cache/template-cache.ts @@ -1,23 +1,16 @@ -/** Cache implementation used by the `$templateCache` service. */ -export type TemplateCache = Map; +/** Public contract implemented by the `$templateCache` injectable. */ +export type TemplateCacheService = Map; + +/** Cache implementation accepted by `$templateCache` configuration. */ +export type TemplateCache = TemplateCacheService; /** - * Provides an instance of a cache that can be used to store and retrieve template content. + * Declarative configuration accepted by + * `NgModule.config({ $templateCache: ... })`. */ -export class TemplateCacheProvider { - cache: TemplateCache; - - /** - * Creates the in-memory template cache backing store. - */ - constructor() { - this.cache = new Map(); - } - +export interface TemplateCacheConfig { /** - * Returns the singleton template cache instance. + * Cache instance returned by `$templateCache`. */ - $get(): TemplateCache { - return this.cache; - } + cache?: TemplateCache; } diff --git a/src/services/template-request/template-request.ts b/src/services/template-request/template-request.ts index 685e1fbe6..30046e314 100644 --- a/src/services/template-request/template-request.ts +++ b/src/services/template-request/template-request.ts @@ -1,5 +1,7 @@ -import { _http, _templateCache } from "../../injection-tokens.ts"; -import { defaultHttpResponseTransform } from "../http/http.ts"; +import { + defaultHttpResponseTransform, + mergeHttpHeaderDefaults, +} from "../http/http.ts"; import { extend, isArray } from "../../shared/utils.ts"; /** @@ -17,86 +19,77 @@ import { extend, isArray } from "../../shared/utils.ts"; export type TemplateRequestService = (templateUrl: string) => Promise; /** - * Provider for the `$templateRequest` service. - * - * Fetches templates via HTTP and caches them in `$templateCache`. - * Templates are assumed trusted. - * - * This provider allows configuring per-request `$http` options such as headers, - * timeout, or transform functions via `httpOptions`. - * - * Option A: - * - Provide a sensible default for template fetching (e.g. `Accept: text/html`) - * - Keep `httpOptions` overridable during config phase + * Declarative configuration accepted by + * `NgModule.config({ $templateRequest: ... })`. */ -export class TemplateRequestProvider { +export interface TemplateRequestConfig { /** - * Optional `$http.get()` config applied to every template request. - * - * This is merged on top of the default template request config: - * - `cache: $templateCache` - * - `transformResponse`: with `defaultHttpResponseTransform` removed - * - * Use this to set template-specific defaults such as custom headers, - * timeouts, credentials, etc. - * + * `$http.get()` options merged into every template request. */ - httpOptions: ng.RequestShortcutConfig; + httpOptions?: ng.HttpRequestOptions; +} - constructor() { - /** - * Default options for template requests. - * Keeps behavior aligned with callers that previously used `$http` directly - * and set `Accept: text/html`. - */ - this.httpOptions = { - headers: { - Accept: "text/html", - }, - }; +/** @internal */ +export function createTemplateRequestHttpOptions(): ng.HttpRequestOptions { + return { + headers: { + Accept: "text/html", + }, + }; +} + +/** @internal */ +export function applyTemplateRequestConfig( + current: ng.HttpRequestOptions, + config: TemplateRequestConfig, +): ng.HttpRequestOptions { + const httpOptions = config.httpOptions; + + if (httpOptions === undefined) return current; + + const headers = httpOptions.headers; + const currentHeaders = current.headers; + const next = { + ...current, + ...httpOptions, + }; + + if (headers !== undefined) { + next.headers = mergeHttpHeaderDefaults(currentHeaders, headers); } - $get = [ - _templateCache, - _http, - /** - * Creates the `$templateRequest` service. - */ - ($templateCache: ng.TemplateCacheService, $http: ng.HttpService) => { - /** - * Fetch a template via HTTP and cache it. - * - * @param templateUrl URL of the template. - * @returns Resolves with template content. - */ - const fetchTemplate = async (templateUrl: string): Promise => { - // Filter out default transformResponse for template requests - let transformResponse = $http.defaults.transformResponse ?? null; + return next; +} - if (isArray(transformResponse)) { - transformResponse = transformResponse.filter( - (x) => x !== defaultHttpResponseTransform, - ); - } else if (transformResponse === defaultHttpResponseTransform) { - transformResponse = null; - } +/** @internal */ +export function createTemplateRequestService( + $templateCache: ng.TemplateCacheService, + $http: ng.HttpService, + httpOptions: ng.HttpRequestOptions, +): TemplateRequestService { + return async (templateUrl: string): Promise => { + let transformResponse = $http.defaults.transformResponse ?? null; - const config = extend( - { - cache: $templateCache, - transformResponse, - }, - this.httpOptions, - ) as ng.RequestShortcutConfig; + if (isArray(transformResponse)) { + transformResponse = transformResponse.filter( + (transform) => transform !== defaultHttpResponseTransform, + ); + } else if (transformResponse === defaultHttpResponseTransform) { + transformResponse = null; + } - return $http.get(templateUrl, config).then((response) => { - $templateCache.set(templateUrl, response.data); + const config = extend( + { + cache: $templateCache, + transformResponse, + }, + httpOptions, + ) as ng.HttpRequestOptions; - return response.data; - }); - }; + return $http.get(templateUrl, config).then((response) => { + $templateCache.set(templateUrl, response.data); - return fetchTemplate; - }, - ]; + return response.data; + }); + }; } diff --git a/src/services/utility/README.md b/src/services/utility/README.md new file mode 100644 index 000000000..6c147c568 --- /dev/null +++ b/src/services/utility/README.md @@ -0,0 +1,148 @@ +# Utility Services + +This README covers the Tier 4 utility-service contract for: + +- `$log` +- `$exceptionHandler` +- `$eventBus` / `EventBus` +- `$sce` + +These services should stay intentionally small. Most lifecycle, reactivity, +recovery, scheduling, and native-resource contracts are not applicable unless a +specific service already owns that behavior. + +## `$log` + +Responsibility: + +- Provide injectable logging methods: `log`, `info`, `warn`, `error`, and + `debug`. +- Normalize `Error` objects into readable console output when using the default + console-backed logger. + +Policy: + +- `debug` controls whether `$log.debug(...)` writes. +- `logger` can replace the console-backed implementation. +- No reactive state, diagnostics model, or log buffering is introduced. + +Failure: + +- `$log` does not catch logger failures. A custom logger owns its own error + behavior. + +Dependency replacement and composition: + +- `$log` replaces direct `console` coupling in framework and app code. +- Reporting pipelines should compose by supplying a custom logger or decorating + `$log`; `$log` itself remains a thin facade. + +## `$exceptionHandler` + +Responsibility: + +- Provide the central AngularTS error sink for framework-managed failures. +- Default behavior is fail-fast: rethrow the received value unchanged. + +Policy: + +- `exceptionHandler.handler` can replace the default function. +- Custom handlers should report and rethrow because AngularTS assumes + `$exceptionHandler` does not hide broken app state. +- No retry, recovery, or diagnostics model is introduced here. + +Failure: + +- The default handler rethrows `Error` instances, primitive values, objects, + `null`, and `undefined` unchanged. +- The returned service wrapper always delegates to the latest runtime-owned + handler. + +Dependency replacement and composition: + +- `$exceptionHandler` replaces scattered framework try/catch reporting hooks. +- Application reporting, telemetry, and redaction compose inside the configured + handler. + +## `$eventBus` / `EventBus` + +Responsibility: + +- Provide application-wide asynchronous publish/subscribe messaging for + decoupled producers and consumers. +- Expose the singleton `$eventBus` through dependency injection and on the + Angular service for non-DI integrations. + +Policy: + +- Delivery is asynchronous through `queueMicrotask`. +- Listener invocation order follows subscription order from a publish-time + snapshot. +- `subscribeOnce` removes its listener before first invocation. +- Passing an AngularTS scope as the listener context makes that scope the + lifecycle owner: `$destroy` auto-unsubscribes the listener. +- Queued deliveries skip scope-owned listeners when the owning scope is + destroyed before the microtask runs. + +Failure: + +- Subscriber exceptions are forwarded to `$exceptionHandler`. +- One failing subscriber does not stop delivery to remaining subscribers. +- Disposed buses ignore subscription and publish attempts. + +Dependency replacement and composition: + +- `$eventBus` replaces ad hoc global callback registries for cross-boundary app + events. +- Scope events remain the better primitive for parent/child scope-tree + communication. +- Realtime services, worker services, workflows, and app integrations can + publish onto `$eventBus`; `$eventBus` does not own their lifecycle or state. + +## `$sce` + +Responsibility: + +- Provide Strict Contextual Escaping helpers and trusted-value wrappers. +- Enforce trusted resource URL policy for template/resource loading and + sensitive bindings. + +Policy: + +- SCE is enabled by default. +- `sce.enabled` can disable SCE, but user docs classify this as risky. +- `sceDelegate.trustedResourceUrlList` and + `sceDelegate.bannedResourceUrlList` define resource URL policy; banned + patterns override trusted patterns. +- URL and media contexts are sanitized when possible; resource URLs must match + policy or be explicitly trusted. + +Failure: + +- Invalid trust contexts and trust values route through `$exceptionHandler` or + throw according to the SCE delegate path. +- Untrusted resource URLs are blocked. +- HTML sanitization requires a sanitizer; without one, unsafe HTML trust checks + fail rather than silently rendering unsafe content. + +Dependency replacement and composition: + +- `$sce` replaces scattered manual escaping and URL allowlist checks at + sensitive binding boundaries. +- It composes with `$templateRequest`, directives that bind privileged + contexts, security policy docs, and optional sanitizer modules. + +## Test Harness + +- `src/services/log/log.spec.ts` verifies injection, default console behavior, + debug policy, error formatting, partial config merging, and custom logger + replacement. +- `src/services/exception/exception.spec.ts` verifies default rethrow behavior, + configured handler delegation, primitive/object rethrows, and late handler + replacement. +- `src/services/event-bus/event-bus.spec.ts` verifies subscription, unsubscription, + contexts, one-time listeners, asynchronous publish behavior, disposal, and + exception-handler delegation. +- `src/services/sce/sce.spec.ts` verifies trusted contexts, delegate override, + resource URL policy, banned-list precedence, URL sanitization, and unsafe HTML + failure behavior. diff --git a/src/services/utility/retained-scheduler.html b/src/services/utility/retained-scheduler.html new file mode 100644 index 000000000..2c8aa4310 --- /dev/null +++ b/src/services/utility/retained-scheduler.html @@ -0,0 +1,22 @@ + + + + + AngularTS Retained Scheduler Test Runner + + + + + + + + + + + +
+ + diff --git a/src/services/utility/retained-scheduler.spec.ts b/src/services/utility/retained-scheduler.spec.ts new file mode 100644 index 000000000..c332fd91f --- /dev/null +++ b/src/services/utility/retained-scheduler.spec.ts @@ -0,0 +1,155 @@ +// @ts-nocheck +/// +import { createScope } from "../../core/scope/scope.ts"; +import { createCanvasWorkAdapter } from "./retained-scheduler.ts"; +import { waitUntil } from "../../shared/test-utils.ts"; + +describe("Canvas work adapter", () => { + it("defers canvas callbacks while scope is retention-paused", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + adapter.schedule(() => calls.push("immediate")); + + expect(calls).toEqual(["immediate"]); + calls.length = 0; + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("during-pause")); + + await waitUntil(() => calls.length === 0); + expect(calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume"); + + await waitUntil(() => calls.length === 1); + expect(calls).toEqual(["during-pause"]); + }); + + it("is resilient to duplicate pause/resume events", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + scope.$broadcast("$viewRetentionPause"); + + adapter.schedule(() => calls.push("first")); + adapter.schedule(() => calls.push("second")); + + expect(calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume"); + scope.$broadcast("$viewRetentionResume"); + + await waitUntil(() => calls.length === 2); + + expect(calls).toEqual(["first", "second"]); + }); + + it("does not pause for unrelated retention mode", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + adapter.schedule(() => calls.push("immediate")); + + scope.$broadcast("$viewRetentionPause", { _pause: "background" }); + adapter.schedule(() => calls.push("background")); + + await waitUntil(() => calls.length === 2); + expect(calls).toEqual(["immediate", "background"]); + }); + + it("ignores unrelated resume events", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("deferred")); + scope.$broadcast("$viewRetentionResume", { _pause: "background" }); + + await Promise.resolve(); + expect(calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume"); + await waitUntil(() => calls.length === 1); + expect(calls).toEqual(["deferred"]); + }); + + it("keeps queued work deferred when paused again before its flush", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("deferred")); + scope.$broadcast("$viewRetentionResume"); + scope.$broadcast("$viewRetentionPause"); + scope.$broadcast("$viewRetentionResume"); + scope.$broadcast("$viewRetentionPause"); + + await Promise.resolve(); + expect(calls).toEqual([]); + + scope.$broadcast("$viewRetentionResume"); + await waitUntil(() => calls.length === 1); + expect(calls).toEqual(["deferred"]); + }); + + it("does not run queued work after destroy", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("discarded")); + adapter.dispose(); + scope.$broadcast("$viewRetentionResume"); + + await waitUntil(() => calls.length === 0); + expect(calls).toEqual([]); + }); + + it("clears queued work when its scope is destroyed", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("discarded")); + scope.$destroy(); + adapter.schedule(() => calls.push("after-destroy")); + + await Promise.resolve(); + expect(calls).toEqual([]); + }); + + it("does not run a scheduled flush after scope destruction", async () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + scope.$broadcast("$viewRetentionPause"); + adapter.schedule(() => calls.push("discarded")); + scope.$broadcast("$viewRetentionResume"); + scope.$destroy(); + + await Promise.resolve(); + expect(calls).toEqual([]); + }); + + it("disposes idempotently and ignores later work", () => { + const scope = createScope() as ng.Scope; + const calls: string[] = []; + const adapter = createCanvasWorkAdapter(scope); + + adapter.dispose(); + adapter.dispose(); + adapter.schedule(() => calls.push("discarded")); + + expect(calls).toEqual([]); + }); +}); diff --git a/src/services/utility/retained-scheduler.test.ts b/src/services/utility/retained-scheduler.test.ts new file mode 100644 index 000000000..b028924a7 --- /dev/null +++ b/src/services/utility/retained-scheduler.test.ts @@ -0,0 +1,9 @@ +import { test } from "@playwright/test"; +import { expectNoJasmineFailures } from "../../../playwright-jasmine.js"; + +test("retained scheduler unit tests contain no errors", async ({ page }) => { + await expectNoJasmineFailures( + page, + "src/services/utility/retained-scheduler.html", + ); +}); diff --git a/src/services/utility/retained-scheduler.ts b/src/services/utility/retained-scheduler.ts new file mode 100644 index 000000000..f4776b7f3 --- /dev/null +++ b/src/services/utility/retained-scheduler.ts @@ -0,0 +1,136 @@ +import { shouldHandleViewRetentionPause } from "../../shared/utils.ts"; + +interface ScopeRetainedWorkState { + _paused: boolean; + _flushing: boolean; + _destroyed: boolean; + _pending: Array<() => void>; + _deregisterPause: () => void; + _deregisterResume: () => void; + _deregisterDestroy: () => void; +} + +interface ScopeWorkScheduler { + schedule(task: () => void): void; + dispose(): void; +} + +/** + * Internal scope-owned scheduler contract for non-compile heavy workloads. + * + * Adapters that own timers, animation loops, canvas workloads, or game-engine + * frame callbacks call this to keep work paused while a retained route subtree + * is inactive. + */ +export interface CanvasWorkAdapter extends ScopeWorkScheduler { + /** + * Queues one callback for immediate execution or deferred execution while the + * owning scope is paused. + */ + schedule(task: () => void): void; + /** + * Disposes this adapter and clears pending work. + */ + dispose(): void; +} + +function createScopeRetainedWorkState(scope: ng.Scope): ScopeRetainedWorkState { + const state: ScopeRetainedWorkState = { + _paused: false, + _flushing: false, + _destroyed: false, + _pending: [], + _deregisterPause: scope.$on("$viewRetentionPause", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + state._paused = true; + }), + _deregisterResume: scope.$on("$viewRetentionResume", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + if (!state._paused) return; + + state._paused = false; + flushScopeRetainedWorkQueue(state); + }), + _deregisterDestroy: scope.$on("$destroy", () => { + state._destroyed = true; + state._paused = false; + state._flushing = false; + state._pending.length = 0; + state._deregisterPause(); + state._deregisterResume(); + state._deregisterDestroy(); + }), + }; + + return state; +} + +function flushScopeRetainedWorkQueue(state: ScopeRetainedWorkState): void { + if (state._flushing || state._paused || state._pending.length === 0) { + return; + } + + state._flushing = true; + + queueMicrotask(() => { + state._flushing = false; + + if (state._paused || state._destroyed) { + return; + } + + const pending = state._pending.splice(0); + + for (let i = 0, l = pending.length; i < l; i++) { + const task = pending[i]; + + task(); + } + }); +} + +function queueScopeRetainedWork( + scope: ng.Scope, + state: ScopeRetainedWorkState, + task: () => void, +): void { + if (scope.$handler._destroyed || state._destroyed) { + return; + } + + if (state._paused) { + state._pending.push(task); + flushScopeRetainedWorkQueue(state); + return; + } + + task(); +} + +/** + * Creates a scope-owned adapter that pauses queued callbacks during retention. + */ +export function createCanvasWorkAdapter(scope: ng.Scope): CanvasWorkAdapter { + const state = createScopeRetainedWorkState(scope); + + return { + schedule(task: () => void): void { + queueScopeRetainedWork(scope, state, task); + }, + dispose(): void { + if (state._destroyed) return; + + state._destroyed = true; + state._pending.length = 0; + state._deregisterPause(); + state._deregisterResume(); + state._deregisterDestroy(); + }, + }; +} diff --git a/src/services/wasm/README.md b/src/services/wasm/README.md new file mode 100644 index 000000000..efbd8dbeb --- /dev/null +++ b/src/services/wasm/README.md @@ -0,0 +1,236 @@ +# WASM Service + +`$wasm` compiles and instantiates WebAssembly modules and connects AngularTS +reactive targets to Wasm guests. Ordinary application state remains owned by +app-context models or root scopes; a `WasmResource` owns only its instance, +bindings, and teardown. + +## Public Surface + +```ts +const math = $wasm.load<{ add(left: number, right: number): number }>({ + source: new URL("./math.wasm", import.meta.url), +}); + +await math.ready; +math.exports.add(2, 3); +``` + +- `load({ source, imports?, compile?, diagnostics? })` immediately returns one + stable `WasmResource`. +- `source` accepts a URL string, `URL`, `Request`, `Response`, `BufferSource`, + or compiled `WebAssembly.Module`. Native `Request` objects carry credentials, + integrity, headers, and cache policy without framework-specific options. +- `ready` resolves with that resource after fetch and instantiation. +- `status`, `error`, `WasmError.code`, and `WasmError.stage` make lifecycle + failures declarative and distinguish fetch, compile, link, start, and bind + failures. +- `instance`, `module`, and typed `exports` are available after readiness. +- `bind(modelOrScope, options?)` connects either an app model or a DOM scope to + the guest's AngularTS ABI and returns an owned `WasmBinding` whose `target` + retains the model or scope's concrete TypeScript type. +- `dispose()` releases every binding and the module resource without destroying + the bound AngularTS target. Disposing during loading also aborts the module + fetch when no other resource is waiting for the same compilation. + +URL and request sources use streaming compilation when possible. AngularTS +falls back to buffered compilation only when streaming reports a `TypeError`, +normally an incorrect Wasm MIME type. Compilation, linking, and start-function +failures are never retried. Caller-owned requests and responses are cloned. + +## Compilation Ownership + +The owning AppContext caches successful compiled modules and creates a fresh +`WebAssembly.Instance` with each resource's imports. Concurrent loads of the +same normalized URL and compile options share one compilation. `Request` and +`Response` sources share by object identity; requests using `no-store` or +`reload` do not share. Mutable byte buffers bypass the cache. Failed +compilations are evicted so a later load can retry. +The normalized URL cache is a 64-entry least-recently-used cache. Eviction only +removes compilation reuse; active resources retain their compiled module and +instance. + +Native compile options are forwarded without framework translation and are +part of cache identity. AngularTS snapshots the options before asynchronous +compilation so later caller mutation cannot change what the cache key means: + +```ts +const resource = $wasm.load({ + source: new URL("./strings.wasm", import.meta.url), + compile: { + builtins: ["js-string"], + importedStringConstants: "string_constants", + }, +}); +``` + +Disposal releases one reference to a pending compilation. The underlying fetch +is aborted synchronously only after its final waiting resource is disposed. +Runtime teardown disposes every resource, aborts remaining pending +compilations, and clears the AppContext cache. + +Register app-owned resources with the module API: + +```ts +angular.module("game", []).wasm("physics", { + source: new URL("./physics.wasm", import.meta.url), +}); +``` + +`ng-wasm` uses the same service and exposes the `WasmResource`, not bare guest +exports. Templates therefore react to `resource.status` and `resource.error` +without a manual scope refresh, and call `resource.exports` only when ready. + +## Reactive Binding + +```ts +const binding = await physics.bind(playerModel, { + name: "player", + watch: ["position", "health"], +}); +``` + +Names are resolved once by guest facades and all subsequent ABI operations use +an internal numeric handle. App code receives only the binding name, typed +target, lifecycle state, and `dispose()` operation. Active names must be +non-empty and unique within a resource; invalid or destroyed targets reject +with `WasmError.code === "binding"`. +Watched paths publish their current values when binding. Set `initial: false` +only for edge-triggered guests that should receive future changes exclusively. +Destroying the model or scope disposes its binding. Destroying the resource +disposes all bindings. Neither operation destroys the other side. + +## Binding Authors + +The pointer/length ABI, host buffers, and `WasmScopeAbi` are intentionally not +part of ambient `ng`. Language integration authors import them from the direct +service subpath: + +```ts +import { WasmAbi } from "@angular-wave/angular.ts/services/wasm"; + +const abi = WasmAbi.create(); +``` + +Create the ABI before instantiating the guest so `abi.imports` can be supplied +to WebAssembly, then call `abi.attach(instance.exports)` exactly once. There is +no constructor shortcut with a second initialization path. `attach()` accepts +the browser's native `WebAssembly.Exports` value and validates the required +AngularTS memory and allocation exports at runtime. + +The binding-author ABI exposes only `imports`, `disposed`, `attach()`, +`createScope()`, `getScope()`, and `dispose()`. Scope registry mutation, guest +callback dispatch, and host-buffer cleanup are driven by `WasmScope` and the +ABI import object rather than public host methods. +`WasmScope` and `WasmScopeAbi` are interfaces returned by the factory; their +private implementation classes and constructors are not part of the package +contract. + +ABI v3 keeps the v2 transaction, binary, and error operations and changes host +watch delivery from one callback per path to one callback per transaction: + +- `WasmScope.apply({ set, delete }, { origin, echo })` applies one reactive + transaction. Guest `scope_apply` requests carry the options in the same JSON + envelope and suppress self-echo by default. +- `getBinary()` / `setBinary()` and `scope_get_binary` / + `scope_set_binary` transfer owned byte arrays without JSON encoding. +- `WasmAbiError`, `error_code()`, and `error_clear()` let guests distinguish + invalid handles, memory ranges, JSON, unsafe paths, quotas, and unsupported + values after a failure return. +- `ng_scope_on_transaction(scopeHandle, transactionPtr, transactionLen)` + coalesces watched path changes from one microtask into one `{ set, delete, +origin }` payload. Language facades may route that payload back into their + ordinary per-path watch callbacks without losing transaction access. + +When the target is an app-context model, transaction origins flow through +`$restore()` into the model scheduler and `$sync()` loop-prevention contract. +Ordinary scopes receive the same batched DOM reactivity and guest echo policy. + +`WasmAbi.version` is the required reactive ABI version. Guest facades export +`ng_abi_version()` and attachment fails with `unsupported-abi` when the version +is missing or different. This is explicit contract validation, not a legacy +compatibility path. + +Guest pointers and lengths are validated before host reads or writes linear +memory. Scope names and paths are limited to 16 KiB and JSON/string payloads to +16 MiB. Each ABI additionally owns at most 1,024 scopes, 4,096 watches, 1,024 +unreleased result buffers, 64 MiB of result-buffer data, and 32 nested guest +callbacks. Malformed or over-budget guest input returns the ABI operation's +failure value instead of throwing through the application. A guest callback +that traps disposes its binding and is reported through the app context's +configured exception handler. + +The `angular_ts` import namespace is framework-owned. Callers may add imports +to it, but names used by the reactive ABI are rejected instead of being +silently replaced. + +See `integrations/wasm/ABI.md` for memory ownership and import/export details. + +## Runtime Ownership + +Every resource created through one `$wasm` service belongs to that AngularTS +runtime. Runtime teardown disposes those resources and rejects later loads. +Directly constructed low-level ABI instances remain caller-owned. + +## Diagnostics + +Set `diagnostics: true` on a load to publish structured `PerformanceMeasure` +entries. AngularTS emits `angular.ts:wasm:compile`, `:instantiate`, `:load`, +`:bind`, and `:guest-callback`. Entry `detail` includes the source and relevant +fields such as `cacheStatus`, final status, or callback kind. Compilation cache +status is `miss`, `shared-pending`, or `hit`, separating compilation work from +waiting on another resource and settled reuse. Diagnostics are disabled by +default and do not add a separate logging or telemetry service. + +```ts +const resource = $wasm.load({ source: "./physics.wasm", diagnostics: true }); +await resource.ready; + +performance.getEntriesByName("angular.ts:wasm:load"); +``` + +## Typed Contracts + +`integrations/wasm/tool/generate-contract.mjs` turns one validated path/type +manifest into deterministic TypeScript, Rust, Go, C, C++, Zig, +AssemblyScript, and C# contracts: + +```sh +node integrations/wasm/tool/generate-contract.mjs \ + integrations/wasm/contracts/player.json \ + --out integrations/wasm/contracts/generated +``` + +Run `make wasm-contracts-check` to reject missing or stale generated files. +The contracts generate only path constants and value types; they do not add +deep-path generic types to the application API. + +Compiled `WebAssembly.Module` values are structured-cloneable. The +`concepts/wasm-worker` demo compiles once through `$wasm`, sends +`resource.module` through `$worker`, and creates independent main-thread and +worker instances. An app model and `$sync()` keep worker commands and DOM state +declarative. The concept also transfers a shared `WebAssembly.Memory` and +verifies atomic main-thread/worker access under route-scoped COOP/COEP headers. +Its AssemblyScript guest imports that memory; both the window and worker invoke +the guest's atomic `increment` export, so the proof is not implemented with +host-only `Atomics` calls. + +## Tests + +`wasm.spec.ts` covers resource lifecycle, compile deduplication and options, +scope and model binding, scope mutation, retention, ABI safety, watching, +buffer ownership, diagnostics, failure eviction, repeated teardown, explicit +disposal, and runtime teardown. +`wasm-package.test.ts` loads the built root bundle and direct WASM service +subpath together to verify reactive model identity across package entrypoints. +`wasm-worker-concept.test.ts` verifies transfer of one compiled module through +the generic `$worker` service and independent execution in both contexts. Every maintained +language demo also runs `integrations/wasm/abi-conformance.ts` against its real +guest runtime. The harness consumes `abi-fuzz-corpus.json`, keeping malformed +transaction and failure-code cases deterministic across languages. Run +`make test-wasm-browsers` for the focused Chromium, Firefox, and WebKit host ABI +and shared-memory suite. +Run `make benchmark-wasm` to record resource loading, binding, get/set, +transactions, raw binary transfer, concurrent compilation reuse, diagnostics +overhead, cache eviction, serialization payload, and watched-update fan-out +baselines. diff --git a/src/services/wasm/index.ts b/src/services/wasm/index.ts new file mode 100644 index 000000000..fecef8cf1 --- /dev/null +++ b/src/services/wasm/index.ts @@ -0,0 +1,29 @@ +/** Public WebAssembly service and language-binding surface. */ +export { WasmAbi, WasmAbiError, WasmError } from "./wasm.ts"; +export type { + WasmAbiExports, + WasmAbiErrorCode, + WasmBinding, + WasmBindingOptions, + WasmCompileOptions, + WasmErrorCode, + WasmErrorStage, + WasmLoadOptions, + WasmResource, + WasmResourceStatus, + WasmScope, + WasmScopeAbi, + WasmScopeAbiImportObject, + WasmScopeAbiImports, + WasmScopeBindingOptions, + WasmScopeOptions, + WasmScopeReference, + WasmScopeTransaction, + WasmScopeTransactionUpdate, + WasmScopeUpdate, + WasmScopeWatchOptions, + WasmScopeWriteOptions, + WasmService, + WasmSource, + WasmTarget, +} from "./wasm.ts"; diff --git a/src/services/wasm/wasm-package.html b/src/services/wasm/wasm-package.html new file mode 100644 index 000000000..6a74c5d4b --- /dev/null +++ b/src/services/wasm/wasm-package.html @@ -0,0 +1,82 @@ + + + + + AngularTS Wasm package boundary smoke test + + + + + diff --git a/src/services/wasm/wasm-package.test.ts b/src/services/wasm/wasm-package.test.ts new file mode 100644 index 000000000..94a6167cf --- /dev/null +++ b/src/services/wasm/wasm-package.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("built root and Wasm subpath preserve reactive model identity", async ({ + page, +}) => { + await page.goto("/src/services/wasm/wasm-package.html"); + await page.waitForFunction( + () => "__wasmPackageResult" in window || "__wasmPackageError" in window, + ); + + const state = await page.evaluate(() => ({ + error: (window as typeof window & { __wasmPackageError?: string }) + .__wasmPackageError, + result: ( + window as typeof window & { + __wasmPackageResult?: { count: number; reactiveWrite: boolean }; + } + ).__wasmPackageResult, + })); + + expect(state.error).toBeUndefined(); + expect(state.result).toEqual({ count: 7, reactiveWrite: true }); +}); diff --git a/src/services/wasm/wasm-types.spec.ts b/src/services/wasm/wasm-types.spec.ts new file mode 100644 index 000000000..5f37e34f7 --- /dev/null +++ b/src/services/wasm/wasm-types.spec.ts @@ -0,0 +1,83 @@ +/// +import type { Model } from "../../core/app-context/app-context.ts"; +import type { + WasmBinding, + WasmCompileOptions, + WasmLoadOptions, + WasmResource, + WasmScope, + WasmScopeTransaction, + WasmSource, + WasmTarget, +} from "./wasm.ts"; + +describe("$wasm types", () => { + it("uses one reactive-target contract for scopes and app models", () => { + const scope = null as unknown as ng.Scope; + const model = null as unknown as Model<{ count: number }>; + const scopeTarget: WasmTarget = scope; + const modelTarget: WasmTarget = model; + + // @ts-expect-error plain records are not AngularTS reactive targets. + const invalidTarget: WasmTarget = { count: 0 }; + + expect([scopeTarget, modelTarget, invalidTarget].length).toBe(3); + }); + + it("preserves the concrete target type through resource bindings", () => { + const model = null as unknown as Model<{ count: number }>; + const bindModel = ( + resource: WasmResource, + ): Promise> => resource.bind(model); + const readCount = (binding: WasmBinding): number => + binding.target.count; + + expect(bindModel).toBeDefined(); + expect(readCount).toBeDefined(); + }); + + it("accepts native WebAssembly loading inputs", () => { + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const sources: WasmSource[] = [ + "module.wasm", + new URL("https://example.test/module.wasm"), + new Request("https://example.test/module.wasm"), + new Response(bytes), + bytes, + bytes.buffer, + new WebAssembly.Module(bytes), + ]; + + expect(sources.length).toBe(7); + }); + + it("uses standard-shaped WebAssembly compile options", () => { + const compile: WasmCompileOptions = { + builtins: ["js-string"], + importedStringConstants: "string_constants", + }; + const load: WasmLoadOptions = { + source: "module.wasm", + compile, + diagnostics: true, + }; + + expect(load.compile).toBe(compile); + }); + + it("types transactional and binary scope operations", () => { + const transaction: WasmScopeTransaction = { + set: { "position.x": 4 }, + delete: ["stale"], + }; + const useScope = (scope: WasmScope): void => { + scope.apply(transaction, { origin: "guest:test", echo: false }); + scope.setBinary("frame", new Uint8Array([1, 2, 3])); + const bytes: Uint8Array | undefined = scope.getBinary("frame"); + + expect(bytes).toBeDefined(); + }; + + expect(useScope).toBeDefined(); + }); +}); diff --git a/src/services/wasm/wasm-worker-concept.test.ts b/src/services/wasm/wasm-worker-concept.test.ts new file mode 100644 index 000000000..6134782fa --- /dev/null +++ b/src/services/wasm/wasm-worker-concept.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +test("shares one compiled WebAssembly module with a managed worker", async ({ + page, +}) => { + await page.goto("/concepts/wasm-worker/"); + + await expect(page.getByTestId("status")).toHaveText("ready"); + await expect(page.getByTestId("module-shared")).toHaveText("true"); + await expect(page.getByTestId("memory-shared")).toHaveText("true"); + await expect(page.getByTestId("shared-value")).toHaveText("2"); + await expect(page.getByTestId("main-result")).toHaveText("1"); + + await page.getByRole("button", { name: "Increment in worker guest" }).click(); + + await expect(page.getByTestId("worker-result")).toHaveText("3"); + await expect(page.getByTestId("shared-value")).toHaveText("3"); + await expect(page.getByTestId("status")).toHaveText("ready"); +}); diff --git a/src/services/wasm/wasm.html b/src/services/wasm/wasm.html index b48988032..a80f07eac 100644 --- a/src/services/wasm/wasm.html +++ b/src/services/wasm/wasm.html @@ -11,6 +11,7 @@ + diff --git a/src/services/wasm/wasm.spec.ts b/src/services/wasm/wasm.spec.ts index 22e5fd75b..69a128f1d 100644 --- a/src/services/wasm/wasm.spec.ts +++ b/src/services/wasm/wasm.spec.ts @@ -1,9 +1,19 @@ // @ts-nocheck /// +import { AppContext } from "../../core/app-context/app-context.ts"; import { Angular } from "../../angular.ts"; +import { wasmModule } from "../../runtime/wasm.ts"; +import { SCOPE_PROXY_BIND } from "../../core/scope/scope.ts"; import { dealoc } from "../../shared/dom.ts"; -import { wait } from "../../shared/test-utils.ts"; -import { WasmScopeAbi } from "./wasm.ts"; +import { wait, waitUntil } from "../../shared/test-utils.ts"; +import { instantiateWasm, isProxySymbol } from "../../shared/utils.ts"; +import { WasmAbi, WasmAbiError, WasmError } from "./index.ts"; +import type { WasmScopeAbi, WasmScopeUpdate } from "./index.ts"; +import { + createWasmRuntimeState, + createWasmService, + destroyWasmRuntimeState, +} from "./wasm.ts"; describe("WasmScopeAbi", () => { let angular: Angular; @@ -21,8 +31,9 @@ describe("WasmScopeAbi", () => { dealoc(el); el.removeAttribute("ng-bind"); angular = new Angular(); + const installedWasmModule = wasmModule(angular); angular - .bootstrap(el, []) + .bootstrap(el, [installedWasmModule.name]) .invoke( (_$compile_: ng.CompileService, _$rootScope_: ng.Scope, _$wasm_) => { compile = _$compile_; @@ -32,7 +43,8 @@ describe("WasmScopeAbi", () => { ); guest = new GuestMemory(); exports = guest.exports(); - abi = new WasmScopeAbi(exports); + abi = WasmAbi.create(); + abi.attach(exports); imports = abi.imports.angular_ts; }); @@ -44,6 +56,18 @@ describe("WasmScopeAbi", () => { await renderWasmDomUpdate(target); }); + it("creates the low-level ABI through the WasmAbi namespace", () => { + const namespacedAbi = WasmAbi.create(); + + namespacedAbi.attach(exports); + + expect(WasmAbi.create).toEqual(jasmine.any(Function)); + expect(WasmAbi.version).toBe(3); + expect(namespacedAbi.imports.angular_ts.scope_resolve).toEqual( + jasmine.any(Function), + ); + }); + it("reads and writes scope state through numeric handles", () => { const scope = abi.createScope(rootScope, { name: "todoList:main" }); const path = guest.write("items"); @@ -80,31 +104,286 @@ describe("WasmScopeAbi", () => { expect(guest.freed.length).toBeGreaterThan(0); }); - it("targets scopes by name", () => { + it("applies one origin-aware transaction to an app-context model", () => { + const context = new AppContext(); + const model = context.registerModel("wasmState", () => ({ + count: 1, + stale: true, + })); + const changes: any[] = []; + const stop = model.$sync({ + write: (snapshot, change) => { + changes.push({ snapshot, change }); + }, + }); + const scope = abi.createScope(model, { name: "model:wasmState" }); + const transaction = guest.writeJson({ + set: { count: 2, "nested.ready": true }, + delete: ["stale"], + origin: "guest:physics", + echo: false, + }); + + expect( + imports.scope_apply(scope.handle, transaction.ptr, transaction.len), + ).toBe(1); + + context.modelScheduler.flush(); + + expect(model.$snapshot()).toEqual({ + count: 2, + nested: { ready: true }, + }); + expect(changes.length).toBe(1); + expect(changes[0].change.origin).toBe("guest:physics"); + expect(changes[0].change.keys).toEqual( + jasmine.arrayWithExactContents(["count", "stale", "nested"]), + ); + + stop(); + context.destroy(); + }); + + it("suppresses transaction echoes unless the guest requests one", async () => { + const updates: unknown[] = []; + + listenForGuestTransactions(exports, guest, (_scopeHandle, transaction) => { + updates.push(...Object.values(transaction.set ?? {})); + }); + + const scope = abi.createScope(rootScope, { name: "echo:scope" }); + const path = guest.write("count"); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + const silent = guest.writeJson({ + set: { count: 1 }, + origin: "guest:simulation", + }); + + expect(imports.scope_apply(scope.handle, silent.ptr, silent.len)).toBe(1); + await wait(); + expect(updates).toEqual([]); + + const echoed = guest.writeJson({ + set: { count: 2 }, + origin: "guest:simulation", + echo: true, + }); + + expect(imports.scope_apply(scope.handle, echoed.ptr, echoed.len)).toBe(1); + await wait(); + expect(updates).toEqual([2]); + }); + + it("transfers binary scope values without JSON encoding", () => { + const scope = abi.createScope(rootScope, { name: "binary:scope" }); + const path = guest.write("frame"); + const bytes = Uint8Array.from([0, 1, 2, 127, 255]); + const valuePtr = guest.alloc(bytes.byteLength); + + new Uint8Array(guest.memory.buffer, valuePtr, bytes.byteLength).set(bytes); + + expect( + imports.scope_set_binary( + scope.handle, + path.ptr, + path.len, + valuePtr, + bytes.byteLength, + 0, + 0, + ), + ).toBe(1); + expect(rootScope.frame).toEqual(bytes); + + const result = imports.scope_get_binary(scope.handle, path.ptr, path.len); + const resultBytes = new Uint8Array( + guest.memory.buffer, + imports.buffer_ptr(result), + imports.buffer_len(result), + ); + + expect(Array.from(resultBytes)).toEqual(Array.from(bytes)); + imports.buffer_free(result); + }); + + it("reports actionable guest failures", () => { + const path = guest.write("count"); + const malformed = guest.write("{"); + + expect(imports.scope_get(404, path.ptr, path.len)).toBe(0); + expect(imports.error_code()).toBe(WasmAbiError.invalidHandle); + + const scope = abi.createScope(rootScope, { name: "errors:scope" }); + + expect( + imports.scope_apply(scope.handle, malformed.ptr, malformed.len), + ).toBe(0); + expect(imports.error_code()).toBe(WasmAbiError.invalidJson); + + const unsafe = guest.writeJson({ set: { "__proto__.value": true } }); + + expect(imports.scope_apply(scope.handle, unsafe.ptr, unsafe.len)).toBe(0); + expect(imports.error_code()).toBe(WasmAbiError.unsafePath); + + imports.error_clear(); + expect(imports.error_code()).toBe(WasmAbiError.none); + }); + + it("contains malformed guest pointers, lengths, and JSON", () => { const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const name = guest.write("todoList:main"); const path = guest.write("count"); - const value = guest.writeJson(3); + const malformed = guest.write("{"); + const value = guest.writeJson(1); + const outsideMemory = guest.memory.buffer.byteLength + 1; - expect(imports.scope_resolve(name.ptr, name.len)).toBe(scope.handle); expect( - imports.scope_set_named( - name.ptr, - name.len, + imports.scope_set( + scope.handle, path.ptr, path.len, - value.ptr, - value.len, + malformed.ptr, + malformed.len, ), + ).toBe(0); + expect( + imports.scope_set(scope.handle, outsideMemory, 1, value.ptr, value.len), + ).toBe(0); + expect( + imports.scope_set(scope.handle, path.ptr, -1, value.ptr, value.len), + ).toBe(0); + expect( + imports.scope_set(scope.handle, path.ptr, 16 * 1024 + 1, value.ptr, 1), + ).toBe(0); + expect(rootScope.count).toBeUndefined(); + }); + + it("validates guest ABI versions and allocator ranges", () => { + const mismatched = new GuestMemory(); + const mismatchedExports = mismatched.exports(); + + mismatchedExports.ng_abi_version = () => 1; + + expect(() => WasmAbi.create().attach(mismatchedExports)).toThrowError( + "Unsupported AngularTS Wasm ABI version 1; expected 3", + ); + + const invalidAllocatorExports = guest.exports(); + + invalidAllocatorExports.ng_abi_alloc = () => guest.memory.buffer.byteLength; + invalidAllocatorExports.ng_scope_on_bind = () => undefined; + + const invalidAllocatorAbi = WasmAbi.create(); + + invalidAllocatorAbi.attach(invalidAllocatorExports); + const invalidAllocatorScope = invalidAllocatorAbi.createScope(rootScope, { + name: "invalid:allocator", + }); + + expect(() => invalidAllocatorScope.bind()).toThrowError( + "AngularTS Wasm ABI guest allocation exceeds guest memory", + ); + }); + + it("bounds guest-owned resources and recovers released capacity", () => { + const scope = abi.createScope(rootScope, { name: "limits:scope" }); + const path = guest.write("count"); + const buffers: number[] = []; + + rootScope.count = 1; + + for (let index = 0; index < 1024; index++) { + buffers.push(imports.scope_get(scope.handle, path.ptr, path.len)); + } + + expect(buffers.every((handle) => handle > 0)).toBeTrue(); + expect(imports.scope_get(scope.handle, path.ptr, path.len)).toBe(0); + + imports.buffer_free(buffers.pop()); + expect(imports.scope_get(scope.handle, path.ptr, path.len)).toBeGreaterThan( + 0, + ); + + const ownedAbi = abi as any; + + ownedAbi._bufferBytes = 64 * 1024 * 1024; + expect(imports.scope_get(scope.handle, path.ptr, path.len)).toBe(0); + ownedAbi._bufferBytes = 0; + + ownedAbi._watches.clear(); + for (let index = 0; index < 4096; index++) { + ownedAbi._watches.set(index + 1, {}); + } + expect(imports.scope_watch(scope.handle, path.ptr, path.len)).toBe(0); + + ownedAbi._scopes.clear(); + for (let index = 0; index < 1024; index++) { + ownedAbi._scopes.set(index + 1, {}); + } + expect(() => + abi.createScope(rootScope.$new(), { name: "limits:overflow" }), + ).toThrowError("AngularTS Wasm ABI scope limit exceeded"); + }); + + it("contains recursive guest callbacks", () => { + const ownedAbi = abi as any; + const recurse = (): void => { + ownedAbi._runGuestCallback("update", recurse); + }; + + expect(() => ownedAbi._runGuestCallback("update", recurse)).toThrowError( + "AngularTS Wasm ABI guest callback depth exceeded", + ); + expect(ownedAbi._guestCallbackDepth).toBe(0); + }); + + it("uses the current guest memory after memory growth", () => { + const scope = abi.createScope(rootScope, { name: "memory:growth" }); + const path = guest.write("count"); + + rootScope.count = 7; + guest.memory.grow(1); + + const result = imports.scope_get(scope.handle, path.ptr, path.len); + + expect( + guest.readJson(imports.buffer_ptr(result), imports.buffer_len(result)), + ).toBe(7); + imports.buffer_free(result); + }); + + it("accepts managed runtime memory views with a live buffer getter", () => { + const managedExports = guest.exports(); + + managedExports.memory = { + get buffer() { + return guest.memory.buffer; + }, + }; + + const managedAbi = WasmAbi.create(); + + expect(() => managedAbi.attach(managedExports)).not.toThrow(); + }); + + it("targets scopes by name", () => { + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const name = guest.write("todoList:main"); + const path = guest.write("count"); + const value = guest.writeJson(3); + + const handle = imports.scope_resolve(name.ptr, name.len); + + expect(handle).toBe(scope.handle); + expect( + imports.scope_set(handle, path.ptr, path.len, value.ptr, value.len), ).toBe(1); expect(rootScope.count).toBe(3); - const result = imports.scope_get_named( - name.ptr, - name.len, - path.ptr, - path.len, - ); + const result = imports.scope_get(handle, path.ptr, path.len); expect( guest.readJson(imports.buffer_ptr(result), imports.buffer_len(result)), @@ -113,18 +392,40 @@ describe("WasmScopeAbi", () => { imports.buffer_free(result); }); + it("requires non-empty unique scope names", () => { + const original = abi.createScope(rootScope, { name: "todoList:main" }); + + expect(() => abi.createScope(rootScope, { name: "" })).toThrowError( + "Wasm scope name must not be empty", + ); + expect(() => abi.createScope(rootScope, { name: " " })).toThrowError( + "Wasm scope name must not be empty", + ); + expect(() => + abi.createScope(rootScope.$new(), { name: "todoList:main" }), + ).toThrowError("Wasm scope name 'todoList:main' is already bound"); + expect(abi.getScope("todoList:main")).toBe(original); + }); + + it("rejects already-destroyed reactive targets", () => { + const target = rootScope.$new(); + + target.$destroy(); + + expect(() => abi.createScope(target)).toThrowError( + "Cannot bind a destroyed AngularTS reactive target", + ); + }); + it("deletes scope paths by name", () => { - abi.createScope(rootScope, { name: "todoList:main" }); + const scope = abi.createScope(rootScope, { name: "todoList:main" }); rootScope.filters = { status: "open", }; - const name = guest.write("todoList:main"); const path = guest.write("filters.status"); - expect( - imports.scope_delete_named(name.ptr, name.len, path.ptr, path.len), - ).toBe(1); + expect(imports.scope_delete(scope.handle, path.ptr, path.len)).toBe(1); expect(rootScope.filters.status).toBeUndefined(); }); @@ -164,13 +465,10 @@ describe("WasmScopeAbi", () => { try { const result = imports.scope_get(scope.handle, path.ptr, path.len); - expect( - guest.readJson(imports.buffer_ptr(result), imports.buffer_len(result)), - ).toBeNull(); + expect(result).toBe(0); + expect(imports.error_code()).toBe(WasmAbiError.unsafePath); expect(imports.scope_delete(scope.handle, path.ptr, path.len)).toBe(0); expect(({} as Record).wasmPolluted).toBe("inherited"); - - imports.buffer_free(result); } finally { delete (Object.prototype as Record).wasmPolluted; } @@ -178,14 +476,13 @@ describe("WasmScopeAbi", () => { it("runs bridge sync callbacks asynchronously", async () => { const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const name = guest.write("todoList:main"); let bridgeSynced = false; scope.onSync(() => { bridgeSynced = true; }); - expect(imports.scope_sync_named(name.ptr, name.len)).toBe(1); + expect(imports.scope_sync(scope.handle)).toBe(1); expect(bridgeSynced).toBeFalse(); await Promise.resolve(); @@ -214,7 +511,7 @@ describe("WasmScopeAbi", () => { await Promise.resolve(); expect(bridgeSynced).toBeFalse(); - expect(scope.isDisposed()).toBeTrue(); + expect(scope.disposed).toBeTrue(); }); it("returns failure values for unknown handles", () => { @@ -236,15 +533,36 @@ describe("WasmScopeAbi", () => { imports.buffer_free(404); }); - it("requires exports before guest-memory callbacks can run", () => { - const detachedAbi = new WasmScopeAbi(); - const detachedScope = detachedAbi.createScope(rootScope, { - name: "todoList:main", - }); + it("contains guest allocator traps while releasing result buffers", () => { + const trappingExports = guest.exports(); + + trappingExports.ng_abi_free = () => { + throw new WebAssembly.RuntimeError("guest free trapped"); + }; - expect(() => detachedAbi.notifyBind(detachedScope)).toThrowError( - "AngularTS Wasm scope ABI exports are not attached", + const trappingAbi = WasmAbi.create(); + + trappingAbi.attach(trappingExports); + const trappingImports = trappingAbi.imports.angular_ts; + const scope = trappingAbi.createScope(rootScope, { name: "trapping:free" }); + const path = guest.write("count"); + const bufferHandle = trappingImports.scope_get( + scope.handle, + path.ptr, + path.len, ); + + expect(bufferHandle).not.toBe(0); + expect(() => trappingImports.buffer_free(bufferHandle)).not.toThrow(); + expect(trappingImports.buffer_ptr(bufferHandle)).toBe(0); + + trappingAbi.dispose(); + }); + + it("contains guest-memory callbacks before exports are attached", () => { + const detachedAbi = WasmAbi.create(); + + expect(detachedAbi.imports.angular_ts.scope_resolve(0, 0)).toBe(0); }); it("supports direct scope reads, writes, and deletes around empty or missing paths", () => { @@ -261,6 +579,148 @@ describe("WasmScopeAbi", () => { expect(rootScope.profile.name).toBeUndefined(); }); + it("writes through scope-proxy-like nested targets without redefining properties", () => { + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const proxyLike = { + $handler: { + set() { + return true; + }, + }, + $target: {}, + }; + + rootScope.proxyLike = proxyLike; + + expect(scope.set("proxyLike.name", "Ada")).toBeTrue(); + expect(rootScope.proxyLike.name).toBe("Ada"); + }); + + it("falls back to safe writes for raw handler-only scope targets", () => { + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const target = { + $handler: {}, + }; + + rootScope.$target.$nonscope = ["handlerOnly"]; + rootScope.$target.handlerOnly = target; + + expect(scope.set("handlerOnly.name", "Ada")).toBeTrue(); + expect(target.name).toBe("Ada"); + }); + + it("writes through real scope-proxy nested targets without redefining properties", () => { + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + let assignedName = ""; + const proxy = { + [isProxySymbol]: true, + $target: {}, + }; + + Object.defineProperty(proxy, "name", { + configurable: true, + enumerable: true, + get() { + return assignedName; + }, + set(value) { + assignedName = value; + }, + }); + + rootScope.proxy = proxy; + + expect(scope.set("proxy.name", "Ada")).toBeTrue(); + expect(assignedName).toBe("Ada"); + expect(rootScope.proxy.name).toBe("Ada"); + }); + + it("falls back to safe property writes for malformed scope-proxy-like targets", () => { + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const cases = [ + {}, + { + $handler: {}, + $target: {}, + }, + { + $handler: { + set: "not a function", + }, + $target: {}, + }, + { + $handler: { + set() { + return true; + }, + }, + }, + { + $handler: { + set() { + return true; + }, + }, + $target: "not an object", + }, + { + $handler: null, + $target: {}, + }, + { + $handler() { + return true; + }, + $target: {}, + }, + { + $handler: { + set() { + return true; + }, + }, + $target: null, + }, + { + $handler: { + set() { + return true; + }, + }, + $target() { + return true; + }, + }, + ]; + + cases.forEach((target, index) => { + rootScope[`maybeProxy${index}`] = target; + + expect(scope.set(`maybeProxy${index}.name`, `Ada ${index}`)).toBeTrue(); + expect(rootScope[`maybeProxy${index}`].name).toBe(`Ada ${index}`); + }); + }); + + it("updates the DOM when a Wasm scope mutates a top-level bound scope path", async () => { + rootScope.titleSeen = ""; + + el.setAttribute("ng-bind", "titleSeen"); + compile(el)(rootScope); + + await wait(); + + expect(el.textContent).toBe(""); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + + expect(scope.set("titleSeen", "Updated by Wasm")).toBeTrue(); + + await wait(); + + expect(el.textContent).toBe("Updated by Wasm"); + }); + it("updates the DOM when a Wasm import mutates a bound scope", async () => { rootScope.todo = { title: "Initial", @@ -273,187 +733,1914 @@ describe("WasmScopeAbi", () => { expect(el.textContent).toBe("Initial"); - abi.createScope(rootScope, { name: "todoList:main" }); + const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const name = guest.write("todoList:main"); const path = guest.write("todo.title"); const value = guest.writeJson("Updated by Wasm"); expect( - imports.scope_set_named( - name.ptr, - name.len, - path.ptr, - path.len, - value.ptr, - value.len, - ), + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), ).toBe(1); - expect(imports.scope_sync_named(name.ptr, name.len)).toBe(1); + expect(imports.scope_sync(scope.handle)).toBe(1); await wait(); expect(el.textContent).toBe("Updated by Wasm"); }); - it("notifies guest exports when watched scope paths change", async () => { - const updates: any[] = []; + it("updates repeated DOM when a Wasm import replaces a collection", async () => { + rootScope.items = [{ title: "Initial" }]; + el.innerHTML = '{{ item.title }}'; + compile(el)(rootScope); - exports.ng_scope_on_update = ( - scopeHandle, - pathPtr, - pathLen, - valuePtr, - valueLen, - ) => { - updates.push({ - scopeHandle, - path: guest.read(pathPtr, pathLen), - value: guest.readJson(valuePtr, valueLen), - }); - }; + await wait(); - const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const path = guest.write("count"); - const value = guest.writeJson(4); - const watch = imports.scope_watch(scope.handle, path.ptr, path.len); + expect(el.querySelectorAll("span").length).toBe(1); - expect(watch).toBeGreaterThan(0); - expect( + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("items"); + const value = guest.writeJson([ + { title: "Initial" }, + { title: "Added by Wasm" }, + ]); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + expect(imports.scope_sync(scope.handle)).toBe(1); + await wait(); + + const rows = el.querySelectorAll("span"); + + expect(rows.length).toBe(2); + expect(rows[1].textContent).toBe("Added by Wasm"); + }); + + it("notifies guest exports when watched scope paths change", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, (update) => updates.push(update)); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const watch = imports.scope_watch(scope.handle, path.ptr, path.len); + + expect(watch).toBeGreaterThan(0); + rootScope.count = 4; + + await wait(); + + expect(updates).toEqual([ + { + scopeHandle: scope.handle, + path: "count", + value: 4, + }, + ]); + + expect(imports.scope_unwatch(watch)).toBe(1); + + rootScope.count = 5; + + await wait(); + + expect(updates.length).toBe(1); + }); + + it("coalesces watched paths into one origin-aware guest transaction", async () => { + const transactions: any[] = []; + + listenForGuestTransactions(exports, guest, (scopeHandle, transaction) => { + transactions.push({ scopeHandle, ...transaction }); + }); + + rootScope.count = 1; + rootScope.stale = true; + const scope = abi.createScope(rootScope, { name: "transaction:main" }); + const countPath = guest.write("count"); + const stalePath = guest.write("stale"); + + imports.scope_watch(scope.handle, countPath.ptr, countPath.len); + imports.scope_watch(scope.handle, stalePath.ptr, stalePath.len); + + scope.apply( + { set: { count: 2 }, delete: ["stale"] }, + { origin: "host:simulation" }, + ); + await wait(); + + expect(transactions).toEqual([ + { + scopeHandle: scope.handle, + set: { count: 2 }, + delete: ["stale"], + origin: "host:simulation", + }, + ]); + }); + + it("defers watched update notifications while scope is retention-paused", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, (update) => updates.push(update)); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const watch = imports.scope_watch(scope.handle, path.ptr, path.len); + + expect(watch).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + const value = guest.writeJson(2); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + + await wait(); + + expect(updates).toEqual([]); + + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([ + { + scopeHandle: scope.handle, + path: "count", + value: 2, + }, + ]); + }); + + it("ignores unrelated retention scheduler events for watched updates", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, (update) => updates.push(update)); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionResume"); + rootScope.$emit("$viewRetentionPause", { + _pause: "background", + }); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + + await wait(); + + expect(updates).toContain( + jasmine.objectContaining({ + scopeHandle: scope.handle, + path: "count", + value: 2, + }), + ); + }); + + it("ignores unrelated retention resume events while scheduler updates are paused", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + + rootScope.$emit("$viewRetentionResume", { + _pause: "background", + }); + + await wait(); + + expect(updates).toEqual([]); + + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([ + { + path: "count", + value: 2, + }, + ]); + }); + + it("coalesces queued watched updates while retention-paused", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + rootScope.count = 2; + await wait(); + expect(updates).toEqual([]); + + rootScope.count = 3; + + await wait(); + + expect(updates).toEqual([]); + + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([ + { + path: "count", + value: 3, + }, + ]); + }); + + it("keeps queued watched updates paused when the scope pauses again before flush", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + + rootScope.$emit("$viewRetentionResume"); + rootScope.$emit("$viewRetentionPause"); + + await wait(); + + expect(updates).toEqual([]); + + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([ + { + path: "count", + value: 2, + }, + ]); + }); + + it("drops queued watched updates when the scope is destroyed", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.$emit("$viewRetentionPause"); + + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(1); + + rootScope.$destroy(); + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([]); + }); + + it("drops queued watched updates when the scope is destroyed after resume schedules a flush", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + expect( imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), ).toBe(1); - await wait(); + rootScope.$emit("$viewRetentionResume"); + rootScope.$destroy(); + + await wait(); + + expect(updates).toEqual([]); + }); + + it("shares retention state across wrappers for the same AngularTS scope", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, (update) => updates.push(update)); + + const firstScope = abi.createScope(rootScope, { name: "todoList:first" }); + const secondScope = abi.createScope(rootScope, { name: "todoList:second" }); + const path = guest.write("count"); + const value = guest.writeJson(2); + + expect( + imports.scope_watch(secondScope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + + rootScope.count = 1; + rootScope.$emit("$viewRetentionPause"); + + expect( + imports.scope_set( + secondScope.handle, + path.ptr, + path.len, + value.ptr, + value.len, + ), + ).toBe(1); + + await wait(); + + expect(updates).toEqual([]); + + rootScope.$emit("$viewRetentionResume"); + + await wait(); + + expect(updates).toEqual([ + { + scopeHandle: secondScope.handle, + path: "count", + value: 2, + }, + ]); + }); + + it("initializes watched exports when binding by default", async () => { + const updates: any[] = []; + + rootScope.count = 7; + listenForGuestUpdates(exports, guest, (update) => updates.push(update)); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const dispose = scope.bind({ + watch: ["count"], + }); + + await wait(); + + expect(updates).toEqual([ + { + scopeHandle: scope.handle, + path: "count", + value: 7, + }, + ]); + + dispose(); + }); + + it("allows edge-triggered bindings to skip initial values", async () => { + const updates: WasmScopeUpdate[] = []; + + rootScope.count = 7; + listenForGuestUpdates(exports, guest, (update) => { + updates.push({ ...update, scopeName: "todoList:main" }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const dispose = scope.bind({ + watch: ["count"], + initial: false, + }); + + await wait(); + + expect(updates).toEqual([]); + + rootScope.count = 8; + await wait(); + + expect(updates).toEqual([ + { + scopeHandle: scope.handle, + scopeName: "todoList:main", + path: "count", + value: 8, + }, + ]); + + dispose(); + }); + + it("calls bind and unbind lifecycle exports", () => { + const calls: string[] = []; + + exports.ng_scope_on_bind = (scopeHandle, namePtr, nameLen) => { + calls.push(`bind:${scopeHandle}:${guest.read(namePtr, nameLen)}`); + }; + exports.ng_scope_on_unbind = (scopeHandle) => { + calls.push(`unbind:${scopeHandle}`); + }; + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const dispose = scope.bind(); + + dispose(); + dispose(); + + expect(calls).toEqual([ + `bind:${scope.handle}:todoList:main`, + `unbind:${scope.handle}`, + ]); + }); + + it("unbinds scopes by name and clears owned watches", async () => { + const updates: any[] = []; + + listenForGuestUpdates(exports, guest, ({ path, value }) => { + updates.push({ path, value }); + }); + + const scope = abi.createScope(rootScope, { name: "todoList:main" }); + const name = guest.write("todoList:main"); + const path = guest.write("count"); + const value = guest.writeJson(1); + + expect( + imports.scope_watch(scope.handle, path.ptr, path.len), + ).toBeGreaterThan(0); + expect(imports.scope_unbind(scope.handle)).toBe(1); + expect(scope.disposed).toBeTrue(); + expect(imports.scope_resolve(name.ptr, name.len)).toBe(0); + expect( + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), + ).toBe(0); + + rootScope.count = 2; + + await wait(); + + expect(updates).toEqual([]); + }); + + it("rolls back tracked writes when an atomic transaction fails", () => { + const target = { + $id: 10_001, + $handler: { _destroyed: false }, + $on: () => () => undefined, + $batch: () => { + throw new Error("transaction failed"); + }, + count: 1, + stale: true, + }; + const scope = abi.createScope(target, { name: "rollback:scope" }); + + scope._watchedPaths.set("count", 1); + scope._watchedPaths.set("stale", 1); + + expect(() => + scope.apply({ set: { count: 2 }, delete: ["stale"] }), + ).toThrowError("transaction failed"); + + const rollbackFirst = scope._trackWrite( + "count", + 2, + { origin: "first" }, + false, + ); + const rollbackSecond = scope._trackWrite( + "count", + 3, + { origin: "second" }, + false, + ); + + rollbackSecond(); + rollbackFirst(); + scope._trackWrite("count", 1, {}, false)(); + }); + + it("validates direct binary values and repeated path watches", () => { + const scope = abi.createScope(rootScope, { name: "binary:direct" }); + const firstWatch = scope.watch("frame", () => undefined); + const secondWatch = scope.watch("frame", () => undefined); + const buffer = Uint8Array.from([1, 2, 3]).buffer; + + expect(scope.setBinary("frame", buffer)).toBeTrue(); + expect(Array.from(scope.getBinary("frame"))).toEqual([1, 2, 3]); + expect(scope.setBinary("frame", {})).toBeFalse(); + + const shared = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }).buffer; + const originalSharedArrayBuffer = globalThis.SharedArrayBuffer; + + globalThis.SharedArrayBuffer = shared.constructor; + try { + expect(scope.setBinary("shared", shared)).toBeTrue(); + } finally { + globalThis.SharedArrayBuffer = originalSharedArrayBuffer; + } + + firstWatch(); + firstWatch(); + secondWatch(); + }); + + it("contains the remaining ABI handle and transaction failures", () => { + const path = guest.write("value"); + const binary = guest.write("bytes"); + const transaction = guest.writeJson({ set: { value: 1 } }); + + expect(imports.scope_apply(404, transaction.ptr, transaction.len)).toBe(0); + expect(imports.scope_get_binary(404, path.ptr, path.len)).toBe(0); + expect( + imports.scope_set_binary( + 404, + path.ptr, + path.len, + binary.ptr, + binary.len, + 0, + 0, + ), + ).toBe(0); + + const scope = abi.createScope(rootScope, { name: "abi:failures" }); + + rootScope.value = "not binary"; + expect(imports.scope_get_binary(scope.handle, path.ptr, path.len)).toBe(0); + expect(imports.scope_delete(scope.handle, path.ptr, path.len)).toBe(1); + const missing = guest.write("missing.value"); + + expect(imports.scope_delete(scope.handle, missing.ptr, missing.len)).toBe( + 0, + ); + + const badOptions = [ + 1, + { origin: 1 }, + { echo: "yes" }, + { origin: "x".repeat(16 * 1024 + 1) }, + ]; + + for (const value of badOptions) { + const options = guest.writeJson(value); + + expect( + imports.scope_set_binary( + scope.handle, + path.ptr, + path.len, + binary.ptr, + binary.len, + options.ptr, + options.len, + ), + ).toBe(0); + } + }); + + it("rejects malformed transaction shapes comprehensively", () => { + const scope = abi.createScope(rootScope, { name: "transaction:invalid" }); + const invalid = [ + null, + {}, + { set: [] }, + { delete: "value" }, + { delete: [1] }, + { set: { value: 1 }, delete: ["value"] }, + { delete: ["value", "value"] }, + { set: { "": 1 } }, + Object.assign(Object.create(null), { set: { value: 1 } }), + ]; + + for (const transaction of invalid.slice(0, -1)) { + expect(() => scope.apply(transaction)).toThrow(); + } + + expect(() => scope.apply(invalid.at(-1))).not.toThrow(); + }); + + it("contains invalid pointers, ABI versions, and memory views", () => { + const path = guest.write("value"); + const scope = abi.createScope(rootScope, { name: "pointer:validation" }); + + expect(imports.scope_get(scope.handle, 1.5, path.len)).toBe(0); + expect(imports.scope_get(scope.handle, -1, path.len)).toBe(0); + expect(imports.scope_get(scope.handle, 0, 1)).toBe(0); + + const missingVersion = guest.exports(); + + delete missingVersion.ng_abi_version; + expect(() => WasmAbi.create().attach(missingVersion)).toThrowError( + "Unsupported AngularTS Wasm ABI version -1; expected 3", + ); + + const invalidVersion = guest.exports(); + + invalidVersion.ng_abi_version = () => 1.5; + expect(() => WasmAbi.create().attach(invalidVersion)).toThrowError( + "Unsupported AngularTS Wasm ABI version -1; expected 3", + ); + + const originalSharedArrayBuffer = globalThis.SharedArrayBuffer; + + class TestSharedArrayBuffer {} + + globalThis.SharedArrayBuffer = TestSharedArrayBuffer; + + try { + const sharedMemory = guest.exports(); + + sharedMemory.memory = { buffer: new TestSharedArrayBuffer() }; + expect(() => WasmAbi.create().attach(sharedMemory)).not.toThrow(); + } finally { + globalThis.SharedArrayBuffer = originalSharedArrayBuffer; + } + + const throwingMemory = guest.exports(); + + throwingMemory.memory = { + get buffer() { + throw new Error("memory unavailable"); + }, + }; + expect(() => WasmAbi.create().attach(throwingMemory)).toThrowError( + "WebAssembly module does not export the AngularTS reactive ABI", + ); + }); + + it("drops queued guest transactions and releases outstanding buffers on dispose", async () => { + const scope = abi.createScope(rootScope, { name: "dispose:queued" }); + const path = guest.write("value"); + + rootScope.value = 1; + const buffer = imports.scope_get(scope.handle, path.ptr, path.len); + + abi._queueUpdate({ + scopeHandle: scope.handle, + scopeName: scope.name, + path: "value", + value: 2, + deleted: false, + }); + abi.dispose(); + await Promise.resolve(); + + expect(imports.buffer_ptr(buffer)).toBe(0); + expect(imports.error_code()).toBe(WasmAbiError.disposed); + }); + + it("handles unnamed scopes and safe direct path edge cases", () => { + const unnamed = rootScope.$new(); + + delete unnamed.$scopename; + const scope = abi.createScope(unnamed); + + expect(scope.name).toBe(String(unnamed.$id)); + expect(scope.get("__proto__.polluted")).toBeUndefined(); + + scope._watchedPaths.set("", 1); + scope._trackWrite("", unnamed, {}, false)(); + scope._watchedPaths.set("__proto__.polluted", 1); + scope._trackWrite("__proto__.polluted", undefined, {}, true)(); + + unnamed.scalar = 1; + scope._watchedPaths.set("scalar.child", 1); + expect(scope.set("scalar.child", 2)).toBeTrue(); + expect(scope.delete("")).toBeFalse(); + expect(scope.delete("__proto__.polluted")).toBeFalse(); + }); + + it("skips guest transactions without a callback and omits mixed origins", async () => { + const scope = abi.createScope(rootScope, { name: "transaction:optional" }); + + abi._queueUpdate({ + scopeHandle: scope.handle, + scopeName: scope.name, + path: "first", + value: 1, + deleted: false, + origin: "left", + }); + await Promise.resolve(); + + const transactions = []; + + listenForGuestTransactions(exports, guest, (_handle, transaction) => { + transactions.push(transaction); + }); + abi._queueUpdate({ + scopeHandle: scope.handle, + scopeName: scope.name, + path: "first", + value: 2, + deleted: false, + origin: "left", + }); + abi._queueUpdate({ + scopeHandle: scope.handle, + scopeName: scope.name, + path: "second", + value: 3, + deleted: false, + origin: "right", + }); + await Promise.resolve(); + + expect(transactions).toEqual([ + { + set: { first: 2, second: 3 }, + delete: [], + }, + ]); + }); + + it("reports asynchronous unbind callback faults", async () => { + const report = jasmine.createSpy("report"); + const scope = abi.createScope(rootScope, { name: "unbind:fault" }); + + abi._reportGuestFault = report; + exports.ng_scope_on_unbind = () => { + throw new Error("unbind failed"); + }; + + scope.bind()(); + await Promise.resolve(); + + expect(report).toHaveBeenCalledWith(jasmine.any(Error)); + }); + + it("rejects host payloads larger than the ABI limit", () => { + expect(() => + abi._writeGuestBytes(new Uint8Array(16 * 1024 * 1024 + 1)), + ).toThrowError("AngularTS Wasm ABI payload exceeds 16777216 bytes"); + + abi._bufferBytes = 64 * 1024 * 1024; + expect(() => abi._createResultBytes(Uint8Array.of(1))).toThrow(); + abi._bufferBytes = 0; + + expect(() => abi._reportGuestFault(new Error("guest fault"))).toThrowError( + "guest fault", + ); + }); + + it("exposes one high-level load operation", () => { + expect(wasmService.load).toEqual(jasmine.any(Function)); + }); + + it("loads a stable resource instead of a union result", async () => { + const resource = wasmService.load<{ + add(left: number, right: number): number; + }>({ source: "/src/directive/wasm/math.wasm" }); + + expect(resource.status).toBe("loading"); + + await resource.ready; + + expect(resource.exports.add(2, 3)).toBe(5); + expect(resource.instance).toEqual(jasmine.any(WebAssembly.Instance)); + expect(resource.module).toEqual(jasmine.any(WebAssembly.Module)); + expect(resource.disposed).toBeFalse(); + await expectAsync(resource.bind(rootScope)).toBeRejectedWith( + jasmine.objectContaining({ code: "unsupported-abi" }), + ); + + resource.dispose(); + + expect(resource.disposed).toBeTrue(); + }); + + it("loads native request, response, byte, and compiled module sources", async () => { + const fetched = await fetch("/src/directive/wasm/math.wasm"); + const bytes = await fetched.clone().arrayBuffer(); + const module = await WebAssembly.compile(bytes); + const sources = [ + new Request("/src/directive/wasm/math.wasm"), + fetched, + bytes, + new Uint8Array(bytes), + module, + ]; + + for (const source of sources) { + const resource = wasmService.load<{ + add(left: number, right: number): number; + }>({ source }); + + await resource.ready; + expect(resource.exports.add(2, 3)).toBe(5); + resource.dispose(); + } + + expect(fetched.bodyUsed).toBeFalse(); + }); + + it("falls back from streaming only for TypeError failures", async () => { + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const nativeCompile = WebAssembly.compile; + const compileModule = spyOn(WebAssembly, "compile").and.callFake((source) => + nativeCompile(source), + ); + + spyOn(WebAssembly, "compileStreaming").and.rejectWith( + new TypeError("invalid MIME type"), + ); + + await instantiateWasm(new Response(bytes), {}); + + expect(compileModule).toHaveBeenCalledTimes(1); + }); + + it("does not retry compilation, linking, or guest start failures", async () => { + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const failure = new WebAssembly.CompileError("invalid module"); + const compileModule = spyOn(WebAssembly, "compile"); + + spyOn(WebAssembly, "compileStreaming").and.rejectWith(failure); + + await expectAsync( + instantiateWasm( + new Response(bytes, { + headers: { "Content-Type": "application/wasm" }, + }), + ), + ).toBeRejectedWith(failure); + expect(compileModule).not.toHaveBeenCalled(); + }); + + it("deduplicates compilation while creating independent instances", async () => { + const nativeCompileStreaming = WebAssembly.compileStreaming; + const compileStreaming = spyOn( + WebAssembly, + "compileStreaming", + ).and.callFake((source) => nativeCompileStreaming(source)); + const left = wasmService.load({ + source: "/src/directive/wasm/math.wasm", + }); + const right = wasmService.load({ + source: "/src/directive/wasm/math.wasm", + }); + + await Promise.all([left.ready, right.ready]); + + expect(compileStreaming).toHaveBeenCalledTimes(1); + expect(left.module).toBe(right.module); + expect(left.instance).not.toBe(right.instance); + + left.dispose(); + right.dispose(); + }); + + it("forwards standard compile options and separates cache entries", async () => { + const nativeCompileStreaming = WebAssembly.compileStreaming; + const optionsSeen: unknown[] = []; + const source = "/src/directive/wasm/math.wasm?compile-options-cache"; + + spyOn(WebAssembly, "compileStreaming").and.callFake((source, options) => { + optionsSeen.push(options); + + return ( + nativeCompileStreaming as ( + source: Promise | Response, + options?: ng.WasmCompileOptions, + ) => Promise + )(source, options); + }); + + const baseline = wasmService.load({ + source, + }); + const builtins: string[] = []; + const configured = wasmService.load({ + source, + compile: { builtins }, + }); + + builtins.push("js-string"); + + await Promise.all([baseline.ready, configured.ready]); + + expect(optionsSeen).toEqual([undefined, { builtins: [] }]); + expect(Object.isFrozen(optionsSeen[1])).toBeTrue(); + expect(Object.isFrozen((optionsSeen[1] as any).builtins)).toBeTrue(); + expect(baseline.module).not.toBe(configured.module); + + baseline.dispose(); + configured.dispose(); + }); + + it("bounds the URL compilation cache with least-recently-used eviction", async () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + const service = createWasmService(state); + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const module = await WebAssembly.compile(bytes); + const compileStreaming = spyOn( + WebAssembly, + "compileStreaming", + ).and.resolveTo(module); + + spyOn(window, "fetch").and.callFake( + async () => + new Response(bytes, { + headers: { "Content-Type": "application/wasm" }, + }), + ); + + for (let index = 0; index < 65; index++) { + const resource = service.load({ source: `/lru-${String(index)}.wasm` }); + + await resource.ready; + resource.dispose(); + } + + expect(state.moduleCache.size).toBe(64); + + const evicted = service.load({ source: "/lru-0.wasm" }); + + await evicted.ready; + expect(compileStreaming).toHaveBeenCalledTimes(66); + + evicted.dispose(); + destroyWasmRuntimeState(state); + context.destroy(); + }); + + it("rejects collisions with reserved ABI imports and permits extensions", async () => { + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + const extension = (): number => 1; + const valid = wasmService.load({ + source: module, + imports: { angular_ts: { extension } }, + }); + + await valid.ready; + valid.dispose(); + + const collision = wasmService.load({ + source: module, + imports: { angular_ts: { scope_get: extension } }, + }); + + await expectAsync(collision.ready).toBeRejectedWith( + jasmine.objectContaining({ + code: "load", + stage: "link", + message: jasmine.stringContaining( + "angular_ts.scope_get' is reserved by AngularTS", + ), + }), + ); + collision.dispose(); + }); + + it("keeps shared compilation alive until the final consumer releases it", async () => { + const response = await fetch("/src/directive/wasm/math.wasm"); + let releaseFetch = (): void => undefined; + let fetchSignal: AbortSignal | undefined; + const blocked = new Promise((resolve) => { + releaseFetch = resolve; + }); + + spyOn(window, "fetch").and.callFake(async (_source, init) => { + fetchSignal = init?.signal; + await blocked; + + return response.clone(); + }); + + const left = wasmService.load({ source: "/shared-module.wasm" }); + const right = wasmService.load({ source: "/shared-module.wasm" }); + + left.dispose(); + + expect(fetchSignal?.aborted).toBeFalse(); + + releaseFetch(); + await right.ready; + + expect(right.status).toBe("ready"); + + await expectAsync(left.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + right.dispose(); + }); + + it("binds reactive targets and owns binding disposal", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + }); + const binding = await resource.bind(rootScope, { + name: "runtime:scope", + watch: ["count"], + }); + + expect(binding.name).toBe("runtime:scope"); + expect(binding.target).toBe(rootScope); + expect(binding.disposed).toBeFalse(); + + resource.dispose(); + + expect(binding.disposed).toBeTrue(); + expect(resource.disposed).toBeTrue(); + expect(rootScope.$handler._destroyed).toBeFalse(); + }); + + it("reports invalid binding names with structured errors", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + }); + + await resource.ready; + + await expectAsync( + resource.bind(rootScope, { name: " " }), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: "binding", + stage: "bind", + message: "Wasm scope name must not be empty", + }), + ); + + const binding = await resource.bind(rootScope, { name: "runtime:scope" }); + const duplicateTarget = rootScope.$new(); + + await expectAsync( + resource.bind(duplicateTarget, { name: "runtime:scope" }), + ).toBeRejectedWith( + jasmine.objectContaining({ + code: "binding", + stage: "bind", + message: "Wasm scope name 'runtime:scope' is already bound", + }), + ); + + duplicateTarget.$destroy(); + binding.dispose(); + resource.dispose(); + }); + + it("binds app-owned models and follows their lifecycle", async () => { + const context = new AppContext(); + const model = context.createReactive({ count: 1 }); + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + }); + const binding = await resource.bind(model, { + name: "runtime:model", + watch: ["count"], + }); + + expect(binding.target).toBe(model); + expect(binding.name).toBe("runtime:model"); + expect(binding.disposed).toBeFalse(); + + context.destroy(); + + expect(binding.disposed).toBeTrue(); + expect(resource.disposed).toBeFalse(); + + resource.dispose(); + }); + + it("reports failed loads through resource state and structured errors", async () => { + const resource = wasmService.load({ source: "/missing-module.wasm" }); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ + code: "load", + source: "/missing-module.wasm", + stage: "compile", + }), + ); + + expect(resource.status).toBe("error"); + expect(resource.error).toEqual(jasmine.objectContaining({ code: "load" })); + let exportsError: unknown; + + try { + resource.exports; + } catch (error) { + exportsError = error; + } + + expect(exportsError).toEqual(jasmine.objectContaining({ code: "load" })); + + resource.dispose(); + }); + + it("distinguishes compilation, linking, and start failures", async () => { + const invalid = wasmService.load({ source: new Uint8Array([0]) }); + + await expectAsync(invalid.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "load", stage: "compile" }), + ); + invalid.dispose(); + + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + + spyOn(WebAssembly, "instantiate").and.rejectWith( + new WebAssembly.LinkError("missing import"), + ); + + const linked = wasmService.load({ source: module }); + + await expectAsync(linked.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "load", stage: "link" }), + ); + linked.dispose(); + }); + + it("classifies start-function traps separately from link failures", async () => { + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + + spyOn(WebAssembly, "instantiate").and.rejectWith( + new WebAssembly.RuntimeError("start trapped"), + ); + + const resource = wasmService.load({ source: module }); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "load", stage: "start" }), + ); + resource.dispose(); + }); + + it("preserves HTTP status details in structured load failures", async () => { + const source = new Response("unavailable", { + status: 503, + statusText: "Service Unavailable", + }); + const resource = wasmService.load({ source }); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "load", source, stage: "fetch" }), + ); + expect((resource.error?.cause as Error).message).toBe( + "WebAssembly fetch failed (503 Service Unavailable)", + ); + + resource.dispose(); + }); + + it("reports HTTP failures without an empty status label", async () => { + const source = new Response("unavailable", { + status: 500, + statusText: "", + }); + const resource = wasmService.load({ source }); + + await expectAsync(resource.ready).toBeRejected(); + expect((resource.error?.cause as Error).message).toBe( + "WebAssembly fetch failed (500)", + ); + + resource.dispose(); + }); + + it("classifies mismatched guest ABI versions before binding", async () => { + const mismatchedGuest = new GuestMemory(); + const guestExports = mismatchedGuest.exports(); + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + const instance = { exports: guestExports } as WebAssembly.Instance; + + guestExports.ng_abi_version = () => 1; + + spyOn(WebAssembly, "instantiate").and.resolveTo(instance); + + const resource = wasmService.load({ source: module }); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ + code: "unsupported-abi", + message: "Unsupported AngularTS Wasm ABI version 1; expected 3", + source: module, + }), + ); + expect(resource.status).toBe("error"); + + resource.dispose(); + }); + + it("handles failed readiness for status-only declarative consumers", async () => { + const unhandled = jasmine.createSpy("unhandledrejection"); + const listener = (event: PromiseRejectionEvent) => { + unhandled(event.reason); + event.preventDefault(); + }; + + window.addEventListener("unhandledrejection", listener); + + try { + const resource = wasmService.load({ + source: "/missing-declarative.wasm", + }); + + await waitUntil(() => resource.status === "error"); - expect(updates).toEqual([ - { - scopeHandle: scope.handle, - path: "count", - value: 4, - }, - ]); + expect(resource.status).toBe("error"); + expect(unhandled).not.toHaveBeenCalled(); + resource.dispose(); + } finally { + window.removeEventListener("unhandledrejection", listener); + } + }); - expect(imports.scope_unwatch(watch)).toBe(1); + it("reports guest callback faults and disposes the failed binding", async () => { + const context = new AppContext(); + const report = jasmine.createSpy("reportGuestFault"); + const state = createWasmRuntimeState(context); + const service = createWasmService(state); + const fakeGuest = new GuestMemory(); + const guestExports = fakeGuest.exports(); + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + const instance = { exports: guestExports } as WebAssembly.Instance; - const nextValue = guest.writeJson(5); + context.setExceptionHandler(report); + guestExports.ng_scope_on_transaction = () => { + throw new Error("guest callback failed"); + }; - expect( - imports.scope_set( - scope.handle, - path.ptr, - path.len, - nextValue.ptr, - nextValue.len, - ), - ).toBe(1); + const nativeInstantiate = WebAssembly.instantiate; + + spyOn(WebAssembly, "instantiate").and.callFake((source, imports) => { + if (source === module) { + return Promise.resolve(instance); + } + + return nativeInstantiate(source, imports); + }); + const resource = service.load({ source: module }); + const model = context.createReactive({ count: 0 }); + const binding = await resource.bind(model, { + name: "faulted:model", + watch: ["count"], + initial: false, + }); + + model.count = 1; await wait(); - expect(updates.length).toBe(1); + expect(binding.disposed).toBeTrue(); + expect(report).toHaveBeenCalledWith(jasmine.any(Error)); + + resource.dispose(); + destroyWasmRuntimeState(state); + context.destroy(); }); - it("binds watched exports and can emit the initial value", async () => { - const updates: any[] = []; + it("publishes opt-in lifecycle and guest callback performance entries", async () => { + const measureNames = [ + "angular.ts:wasm:bind", + "angular.ts:wasm:compile", + "angular.ts:wasm:guest-callback", + "angular.ts:wasm:instantiate", + "angular.ts:wasm:load", + ]; - rootScope.count = 7; - exports.ng_scope_on_update = ( - scopeHandle, - pathPtr, - pathLen, - valuePtr, - valueLen, - ) => { - updates.push({ - scopeHandle, - path: guest.read(pathPtr, pathLen), - value: guest.readJson(valuePtr, valueLen), - }); - }; + for (const name of measureNames) performance.clearMeasures(name); - const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const dispose = scope.bindExports(exports, { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + diagnostics: true, + }); + const target = rootScope.$new(); + const binding = await resource.bind(target, { + name: "diagnostics:scope", watch: ["count"], - initial: true, + initial: false, }); + target.count = 1; await wait(); + binding.dispose(); + resource.dispose(); - expect(updates).toEqual([ - { - scopeHandle: scope.handle, - path: "count", - value: 7, - }, - ]); + for (const name of measureNames) { + expect(performance.getEntriesByName(name).length).toBeGreaterThan(0); + } - dispose(); + const compileEntry = performance.getEntriesByName( + "angular.ts:wasm:compile", + )[0] as PerformanceMeasure; + + expect(compileEntry.detail).toEqual( + jasmine.objectContaining({ + cacheStatus: "miss", + source: "/integrations/wasm/c/examples/todo/main.wasm", + }), + ); + + target.$destroy(); + for (const name of measureNames) performance.clearMeasures(name); }); - it("calls bind and unbind lifecycle exports", () => { - const calls: string[] = []; + it("distinguishes compilation misses, shared work, and settled hits", async () => { + const source = "/src/directive/wasm/math.wasm?diagnostic-cache"; - exports.ng_scope_on_bind = (scopeHandle, namePtr, nameLen) => { - calls.push(`bind:${scopeHandle}:${guest.read(namePtr, nameLen)}`); - }; - exports.ng_scope_on_unbind = (scopeHandle) => { - calls.push(`unbind:${scopeHandle}`); + performance.clearMeasures("angular.ts:wasm:compile"); + + const left = wasmService.load({ source, diagnostics: true }); + const right = wasmService.load({ source, diagnostics: true }); + + await Promise.all([left.ready, right.ready]); + + const cached = wasmService.load({ source, diagnostics: true }); + + await cached.ready; + + const statuses = performance + .getEntriesByName("angular.ts:wasm:compile") + .map((entry) => (entry as PerformanceMeasure).detail.cacheStatus); + + expect(statuses).toEqual(["miss", "shared-pending", "hit"]); + + left.dispose(); + right.dispose(); + cached.dispose(); + performance.clearMeasures("angular.ts:wasm:compile"); + }); + + it("releases resources, bindings, watches, and reactive targets under stress", async () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + const service = createWasmService(state); + + for (let index = 0; index < 25; index++) { + const resource = service.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + }); + const target = context.createReactive({ count: index }); + const binding = await resource.bind(target, { + name: `stress:${String(index)}`, + watch: ["count"], + }); + const ownedAbi = resource._abi; + + target.count++; + await wait(); + binding.dispose(); + resource.dispose(); + target.$destroy(); + + expect(ownedAbi._scopes.size).toBe(0); + expect(ownedAbi._watches.size).toBe(0); + expect(ownedAbi._buffers.size).toBe(0); + expect(state.resources.size).toBe(0); + } + + expect(state.moduleCache.size).toBe(1); + expect(state.moduleEntries.size).toBe(0); + expect(context._reactiveModels.size).toBe(0); + + destroyWasmRuntimeState(state); + context.destroy(); + + expect(state.moduleCache.size).toBe(0); + expect(state.resources.size).toBe(0); + expect(context.models.size).toBe(0); + }); + + it("evicts failed compilations so later loads can retry", async () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + const service = createWasmService(state); + const first = service.load({ source: "/missing-cache-entry.wasm" }); + + await expectAsync(first.ready).toBeRejected(); + + expect(state.moduleCache.size).toBe(0); + expect(state.moduleEntries.size).toBe(0); + + first.dispose(); + destroyWasmRuntimeState(state); + context.destroy(); + }); + + it("rejects a binding when its target is destroyed during loading", async () => { + const response = await fetch("/src/directive/wasm/math.wasm"); + let releaseFetch: (response: Response) => void = () => undefined; + const pendingFetch = new Promise((resolve) => { + releaseFetch = resolve; + }); + + spyOn(window, "fetch").and.returnValue(pendingFetch); + + const resource = wasmService.load({ + source: "/src/directive/wasm/math.wasm", + }); + const target = rootScope.$new(); + const binding = resource.bind(target); + + target.$destroy(); + + await expectAsync(binding).toBeRejectedWith( + jasmine.objectContaining({ code: "binding" }), + ); + + releaseFetch(response); + await resource.ready; + resource.dispose(); + }); + + it("rejects an already-destroyed binding target immediately", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm", + }); + const target = rootScope.$new(); + + await resource.ready; + target.$destroy(); + + await expectAsync(resource.bind(target)).toBeRejectedWith( + jasmine.objectContaining({ + code: "binding", + message: "Cannot bind a destroyed AngularTS reactive target", + }), + ); + + resource.dispose(); + }); + + it("rejects readiness when disposed during loading", async () => { + const response = await fetch("/src/directive/wasm/math.wasm"); + + spyOn(window, "fetch").and.returnValue(Promise.resolve(response)); + + const resource = wasmService.load({ + source: "/src/directive/wasm/math.wasm", + }); + + resource.dispose(); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + expect(resource.status).toBe("disposed"); + }); + + it("aborts the module fetch when disposed during loading", async () => { + let fetchSignal: AbortSignal | undefined; + + spyOn(window, "fetch").and.callFake((_input, init) => { + fetchSignal = init?.signal ?? undefined; + + return new Promise((_resolve, reject) => { + fetchSignal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted", "AbortError")); + }); + }); + }); + + const resource = wasmService.load({ + source: "/src/directive/wasm/math.wasm", + }); + + resource.dispose(); + + expect(fetchSignal?.aborted).toBeTrue(); + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + }); + + it("disposes every resource owned by the runtime", async () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + const service = createWasmService(state); + const resource = service.load({ + source: "/src/directive/wasm/math.wasm", + }); + + await resource.ready; + + destroyWasmRuntimeState(state); + destroyWasmRuntimeState(state); + + expect(resource.disposed).toBeTrue(); + expect(rootScope.$handler._destroyed).toBeFalse(); + let runtimeError: unknown; + + try { + service.load({ source: "/src/directive/wasm/math.wasm" }); + } catch (error) { + runtimeError = error; + } + + expect(runtimeError).toEqual( + jasmine.objectContaining({ code: "disposed" }), + ); + + context.destroy(); + }); + + it("exposes loading and disposed resource failures consistently", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm?resource-state", + }); + + expect(() => resource.exports).toThrowError( + "WebAssembly resource is still loading", + ); + + await resource.ready; + + const binding = await resource.bind(rootScope.$new(), { + name: "resource:state", + }); + + binding.dispose(); + binding.dispose(); + resource.dispose(); + resource.dispose(); + + expect(() => resource.exports).toThrowError( + "WebAssembly resource has been disposed", + ); + await expectAsync(resource.bind(rootScope)).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + }); + + it("removes destroyed lifecycle observers before scheduling updates", async () => { + const resource = wasmService.load({ + source: "/src/directive/wasm/math.wasm?lifecycle-observer", + }); + const observer = rootScope.$new(); + const schedule = jasmine.createSpy("schedule"); + const liveHandler = { + $id: 20_001, + _destroyed: false, + _scheduleWatchKeys: schedule, }; - const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const dispose = scope.bindExports(exports); + resource[SCOPE_PROXY_BIND](observer.$handler); + resource[SCOPE_PROXY_BIND](liveHandler); + observer.$destroy(); - dispose(); - dispose(); + await resource.ready; - expect(calls).toEqual([ - `bind:${scope.handle}:todoList:main`, - `unbind:${scope.handle}`, + expect(resource._scopeBindings.size).toBe(1); + expect(schedule).toHaveBeenCalled(); + resource.dispose(); + }); + + it("normalizes binding failures from ABI scope creation", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm?binding-errors", + }); + + await resource.ready; + + spyOn(resource._abi, "createScope").and.throwError( + new WasmError("binding", "known binding failure"), + ); + await expectAsync(resource.bind(rootScope.$new())).toBeRejectedWith( + jasmine.objectContaining({ + code: "binding", + message: "known binding failure", + }), + ); + + resource._abi.createScope.and.callFake(() => { + throw "unknown binding failure"; + }); + await expectAsync(resource.bind(rootScope.$new())).toBeRejectedWith( + jasmine.objectContaining({ + code: "binding", + message: "WebAssembly target binding failed", + }), + ); + + resource.dispose(); + }); + + it("rejects a bind that reaches readiness after resource disposal", async () => { + const resource = wasmService.load({ + source: "/integrations/wasm/c/examples/todo/main.wasm?bind-race", + }); + + await resource.ready; + + const binding = resource.bind(rootScope.$new()); + + resource.dispose(); + + await expectAsync(binding).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + }); + + it("preserves structured and non-Error instantiation failures", async () => { + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + const instantiate = spyOn(WebAssembly, "instantiate"); + + instantiate.and.rejectWith( + new WasmError("load", "structured failure", { stage: "link" }), + ); + const structured = wasmService.load({ source: module }); + + await expectAsync(structured.ready).toBeRejectedWith( + jasmine.objectContaining({ message: "structured failure" }), + ); + structured.dispose(); + + instantiate.and.returnValue(Promise.reject("non-error failure")); + const unknown = wasmService.load({ source: module }); + + await expectAsync(unknown.ready).toBeRejectedWith( + jasmine.objectContaining({ + message: "WebAssembly module failed during link", + }), + ); + unknown.dispose(); + }); + + it("rejects loading after instantiation completes for a disposed resource", async () => { + const module = await WebAssembly.compile( + new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]), + ); + let resolveInstance!: (instance: WebAssembly.Instance) => void; + const instantiateResult = new Promise((resolve) => { + resolveInstance = resolve; + }); + const instantiate = spyOn(WebAssembly, "instantiate").and.returnValue( + instantiateResult, + ); + const resource = wasmService.load({ source: module }); + + await waitUntil(() => instantiate.calls.count() === 1); + resource.dispose(); + resolveInstance({ exports: {} } as WebAssembly.Instance); + + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ code: "disposed" }), + ); + }); + + it("covers source diagnostics and compile-option defaults", async () => { + const response = await fetch("/src/directive/wasm/math.wasm"); + const bytes = await response.clone().arrayBuffer(); + const module = await WebAssembly.compile(bytes); + const sources = [ + new Request("/src/directive/wasm/math.wasm"), + new Response(bytes), + module, + bytes, + ]; + + performance.clearMeasures("angular.ts:wasm:compile"); + + for (const source of sources) { + const resource = wasmService.load({ source, diagnostics: true }); + + await resource.ready; + resource.dispose(); + } + + const descriptions = performance + .getEntriesByName("angular.ts:wasm:compile") + .map((entry) => (entry as PerformanceMeasure).detail.source); + + expect(descriptions).toEqual([ + new URL("/src/directive/wasm/math.wasm", location.href).href, + "Response", + "WebAssembly.Module", + "BufferSource", ]); + performance.clearMeasures("angular.ts:wasm:compile"); + + const configured = wasmService.load({ + source: "/src/directive/wasm/math.wasm?string-constants", + compile: { importedStringConstants: "constants" }, + }); + + await configured.ready; + configured.dispose(); }); - it("unbinds scopes by name and clears owned watches", async () => { - const updates: any[] = []; + it("handles invalid URL cache keys and non-Error compilation failures", async () => { + spyOn(WebAssembly, "compile").and.returnValue( + Promise.reject("compile failed"), + ); + + const invalidUrl = wasmService.load({ + source: "http://[", + }); + + await expectAsync(invalidUrl.ready).toBeRejected(); + invalidUrl.dispose(); + + const bytes = wasmService.load({ source: new Uint8Array([0]) }); + + await expectAsync(bytes.ready).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringContaining("WebAssembly compilation failed"), + }), + ); + bytes.dispose(); + }); + + it("classifies URL and Request TypeErrors as fetch failures", async () => { + spyOn(window, "fetch").and.rejectWith(new TypeError("network failed")); + + const sources = [ + new URL("/url-failure.wasm", location.href), + new Request("/request-failure.wasm"), + ]; + + for (const source of sources) { + const resource = wasmService.load({ source }); - exports.ng_scope_on_update = ( - _scopeHandle, - pathPtr, - pathLen, - valuePtr, - valueLen, - ) => { - updates.push({ - path: guest.read(pathPtr, pathLen), - value: guest.readJson(valuePtr, valueLen), + await expectAsync(resource.ready).toBeRejectedWith( + jasmine.objectContaining({ stage: "fetch" }), + ); + resource.dispose(); + } + }); + + it("skips pending cache entries while trimming settled modules", () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + + for (let index = 0; index < 65; index++) { + state.moduleCache.set(`entry-${String(index)}`, { + _settled: index !== 0, }); - }; + } - const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const name = guest.write("todoList:main"); - const path = guest.write("count"); - const value = guest.writeJson(1); + const service = createWasmService(state); + const resource = service.load({ source: "/trigger-cache-trim.wasm" }); - expect( - imports.scope_watch_named(name.ptr, name.len, path.ptr, path.len), - ).toBeGreaterThan(0); - expect(imports.scope_unbind_named(name.ptr, name.len)).toBe(1); - expect(scope.isDisposed()).toBeTrue(); - expect(imports.scope_resolve(name.ptr, name.len)).toBe(0); - expect( - imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len), - ).toBe(0); + expect(state.moduleCache.size).toBeLessThanOrEqual(65); + resource.dispose(); + destroyWasmRuntimeState(state); + context.destroy(); + }); - rootScope.count = 2; + it("aborts pending module entries left in runtime ownership", () => { + const context = new AppContext(); + const state = createWasmRuntimeState(context); + const abort = jasmine.createSpy("abort"); - await wait(); + state.moduleEntries.add({ + _settled: false, + _controller: { abort }, + }); - expect(updates).toEqual([]); + destroyWasmRuntimeState(state); + + expect(abort).toHaveBeenCalledTimes(1); + context.destroy(); + }); + + it("rejects attachment and scope creation after ABI disposal", () => { + const ownedAbi = WasmAbi.create(); + + ownedAbi.dispose(); + ownedAbi.dispose(); + + expect(ownedAbi.disposed).toBeTrue(); + expect(() => ownedAbi.attach(exports)).toThrowError( + "Cannot attach exports to a disposed Wasm scope ABI", + ); + expect(() => ownedAbi.createScope(rootScope)).toThrowError( + "Cannot create a scope from a disposed Wasm scope ABI", + ); + }); + + it("does not replace exports attached to an ABI", () => { + const ownedAbi = WasmAbi.create(); + + ownedAbi.attach(exports); + + expect(() => ownedAbi.attach(exports)).not.toThrow(); + expect(() => ownedAbi.attach(new GuestMemory().exports())).toThrowError( + "Wasm scope ABI exports are already attached", + ); }); - it("is available from the $wasm service", () => { - expect(wasmService.createScopeAbi).toEqual(jasmine.any(Function)); - expect(wasmService.scope).toEqual(jasmine.any(Function)); + it("validates native WebAssembly exports when attaching", () => { + const ownedAbi = WasmAbi.create(); + const nativeExports: WebAssembly.Exports = {}; + + expect(() => ownedAbi.attach(nativeExports)).toThrowError( + "WebAssembly module does not export the AngularTS reactive ABI", + ); }); }); +function listenForGuestTransactions( + exports: any, + guest: GuestMemory, + listener: (scopeHandle: number, transaction: any) => void, +): void { + exports.ng_scope_on_transaction = ( + scopeHandle: number, + transactionPtr: number, + transactionLen: number, + ) => { + listener(scopeHandle, guest.readJson(transactionPtr, transactionLen)); + }; +} + +function listenForGuestUpdates( + exports: any, + guest: GuestMemory, + listener: (update: { + scopeHandle: number; + path: string; + value: unknown; + }) => void, +): void { + listenForGuestTransactions(exports, guest, (scopeHandle, transaction) => { + for (const [path, value] of Object.entries(transaction.set ?? {})) { + listener({ scopeHandle, path, value }); + } + for (const path of transaction.delete ?? []) { + listener({ scopeHandle, path, value: undefined }); + } + }); +} + class GuestMemory { memory = new WebAssembly.Memory({ initial: 1 }); @@ -464,6 +2651,7 @@ class GuestMemory { exports() { return { memory: this.memory, + ng_abi_version: () => 3, ng_abi_alloc: (size: number) => this.alloc(size), ng_abi_free: (ptr: number, len: number) => { this.freed.push({ ptr, len }); @@ -523,12 +2711,13 @@ async function renderWasmDomUpdate(target: HTMLElement): Promise { await wait(); const guest = new GuestMemory(); - const abi = new WasmScopeAbi(guest.exports()); + const abi = WasmAbi.create(); + + abi.attach(guest.exports()); const imports = abi.imports.angular_ts; - abi.createScope(rootScope, { name: "todoList:main" }); + const scope = abi.createScope(rootScope, { name: "todoList:main" }); - const name = guest.write("todoList:main"); const path = guest.write("todo.message"); for (let count = 1; count <= 3; count++) { @@ -536,15 +2725,8 @@ async function renderWasmDomUpdate(target: HTMLElement): Promise { const value = guest.writeJson(`Updated by Wasm ${count}`); - imports.scope_set_named( - name.ptr, - name.len, - path.ptr, - path.len, - value.ptr, - value.len, - ); - imports.scope_sync_named(name.ptr, name.len); + imports.scope_set(scope.handle, path.ptr, path.len, value.ptr, value.len); + imports.scope_sync(scope.handle); await wait(); } } diff --git a/src/services/wasm/wasm.ts b/src/services/wasm/wasm.ts index d713df3fa..0ee9d78e1 100644 --- a/src/services/wasm/wasm.ts +++ b/src/services/wasm/wasm.ts @@ -1,12 +1,39 @@ import { + compileWasm, deleteProperty, - instantiateWasm, isNumber, isProxy, + shouldHandleViewRetentionPause, } from "../../shared/utils.ts"; +import type { AppContext, Model } from "../../core/app-context/app-context.ts"; +import { + SCOPE_PROXY_BIND, + type Scope, + type ScopeProxyBindable, +} from "../../core/scope/scope.ts"; const WASM_SCOPE_IMPORT_NAMESPACE = "angular_ts"; +const WASM_ABI_VERSION = 3; + +const WASM_SCOPE_DEFAULT_ORIGIN = "wasm"; + +const MAX_WASM_ABI_PATH_BYTES = 16 * 1024; + +const MAX_WASM_ABI_PAYLOAD_BYTES = 16 * 1024 * 1024; + +const MAX_WASM_ABI_SCOPES = 1024; + +const MAX_WASM_ABI_WATCHES = 4096; + +const MAX_WASM_ABI_RESULT_BUFFERS = 1024; + +const MAX_WASM_ABI_BUFFER_BYTES = 64 * 1024 * 1024; + +const MAX_WASM_GUEST_CALLBACK_DEPTH = 32; + +const MAX_WASM_MODULE_CACHE_ENTRIES = 64; + const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -17,21 +44,118 @@ const UNSAFE_SCOPE_PATH_KEYS = new Set([ "prototype", ]); -/** Options for loading a WebAssembly module through `$wasm`. */ -export interface WasmOptions { - /** - * When `false`, `$wasm` resolves to `instance.exports`. - * When `true`, `$wasm` resolves to the full instantiation result. - */ - raw?: boolean; - /** Additional runtime-specific options. */ - [key: string]: unknown; +/** Source accepted by the WebAssembly loader. */ +export type WasmSource = + | string + | URL + | Request + | Response + | BufferSource + | WebAssembly.Module; + +/** Standard WebAssembly compilation options forwarded without translation. */ +export interface WasmCompileOptions { + /** Native WebAssembly builtin modules enabled while compiling. */ + builtins?: readonly string[]; + /** Native module name used for imported global string constants. */ + importedStringConstants?: string; +} + +/** Declarative options for loading one WebAssembly module. */ +export interface WasmLoadOptions { + /** URL, request, response, bytes, or compiled WebAssembly module. */ + source: WasmSource; + /** Imports supplied in addition to the AngularTS reactive ABI. */ + imports?: WebAssembly.Imports; + /** Standard options forwarded to WebAssembly compilation. */ + compile?: WasmCompileOptions; + /** Publish lifecycle timing entries through the browser Performance API. */ + diagnostics?: boolean; +} + +/** + * Reactive AngularTS value that can be exposed to a WebAssembly guest. + * + * App-context models implement `ng.Scope`, so the same contract covers both + * durable models and root/view scopes without a redundant model union. + */ +export type WasmTarget = ng.Scope; + +/** Options for binding one reactive target to a WebAssembly guest. */ +export interface WasmBindingOptions { + /** Stable name exposed to the guest. */ + name?: string; + /** Reactive paths delivered to the guest's update callback. */ + watch?: readonly string[]; + /** Deliver each watched path's current value when binding. Defaults to `true`. */ + initial?: boolean; +} + +/** Error categories reported by the high-level WebAssembly host. */ +export type WasmErrorCode = "load" | "binding" | "disposed" | "unsupported-abi"; + +/** Lifecycle stage at which a WebAssembly operation failed. */ +export type WasmErrorStage = "fetch" | "compile" | "link" | "start" | "bind"; + +/** Structured error raised by the high-level WebAssembly host. */ +export class WasmError extends Error { + readonly code: WasmErrorCode; + readonly source?: WasmSource; + readonly stage?: WasmErrorStage; + + constructor( + code: WasmErrorCode, + message: string, + options: { + cause?: unknown; + source?: WasmSource; + stage?: WasmErrorStage; + } = {}, + ) { + super(message, { cause: options.cause }); + this.name = "WasmError"; + this.code = code; + this.source = options.source; + this.stage = options.stage; + } +} + +/** Active connection between one AngularTS target and a WebAssembly guest. */ +export interface WasmBinding { + readonly name: string; + readonly target: TTarget; + readonly disposed: boolean; + dispose(): void; +} + +/** Lifecycle state of a WebAssembly resource. */ +export type WasmResourceStatus = "loading" | "ready" | "error" | "disposed"; + +/** Loaded WebAssembly module with owned reactive bindings and lifecycle. */ +export interface WasmResource< + TExports extends WebAssembly.Exports = WebAssembly.Exports, +> { + readonly source: WasmSource; + readonly status: WasmResourceStatus; + readonly ready: Promise>; + readonly error: WasmError | undefined; + readonly instance: WebAssembly.Instance; + readonly module: WebAssembly.Module; + readonly exports: TExports; + readonly disposed: boolean; + bind( + target: TTarget, + options?: WasmBindingOptions, + ): Promise>; + dispose(): void; } /** WebAssembly exports required by the language-neutral AngularTS ABI. */ export interface WasmAbiExports { /** Linear memory used for ABI string and JSON payload exchange. */ - memory: WebAssembly.Memory; + memory: Pick; + /** Returns the AngularTS reactive ABI version implemented by this guest. */ + ng_abi_version(): number; /** Allocates `size` bytes in guest memory and returns the pointer. */ ng_abi_alloc(size: number): number; /** Releases a pointer previously returned by `ng_abi_alloc`. */ @@ -44,13 +168,11 @@ export interface WasmAbiExports { ): void; /** Optional callback invoked before a scope handle is unbound. */ ng_scope_on_unbind?(scopeHandle: number): void; - /** Optional callback invoked when a watched scope path changes. */ - ng_scope_on_update?( + /** Optional callback invoked with one watched scope transaction. */ + ng_scope_on_transaction?( scopeHandle: number, - pathPtr: number, - pathLen: number, - valuePtr: number, - valueLen: number, + transactionPtr: number, + transactionLen: number, ): void; } @@ -73,13 +195,63 @@ export interface WasmScopeUpdate { path: string; /** Current value read from the scope path. */ value: unknown; + /** Whether the path was removed instead of assigned. */ + deleted: boolean; + /** Origin attached to the write, when the update came through the ABI. */ + origin?: string; +} + +/** Coalesced watched transaction delivered from AngularTS to a Wasm guest. */ +export interface WasmScopeTransactionUpdate { + /** Host-side numeric scope handle. */ + scopeHandle: number; + /** Stable scope name. */ + scopeName: string; + /** Atomic set/delete patch observed during one scheduler turn. */ + transaction: WasmScopeTransaction; + /** Shared origin when every coalesced change has the same origin. */ + origin?: string; +} + +/** Atomic set/delete patch applied to one AngularTS reactive target. */ +export interface WasmScopeTransaction { + /** Dot-separated paths and their replacement values. */ + set?: Readonly>; + /** Dot-separated paths removed by the transaction. */ + delete?: readonly string[]; } +/** Origin and echo behavior for one Wasm scope write. */ +export interface WasmScopeWriteOptions { + /** Stable source identifier used to prevent synchronization loops. */ + origin?: string; + /** Deliver this write back to guest watches. Defaults to `true` for host calls. */ + echo?: boolean; +} + +/** Machine-readable failures available to guests through `error_code`. */ +export const WasmAbiError = Object.freeze({ + none: 0, + disposed: 1, + invalidHandle: 2, + invalidPointer: 3, + invalidLength: 4, + invalidJson: 5, + unsafePath: 6, + limitExceeded: 7, + invalidTransaction: 8, + unsupportedValue: 9, + operationFailed: 10, +} as const); + +/** Numeric value returned by the guest-facing `error_code` import. */ +export type WasmAbiErrorCode = (typeof WasmAbiError)[keyof typeof WasmAbiError]; + /** Options for binding an AngularTS scope to Wasm lifecycle callbacks. */ -export interface WasmScopeBindingOptions extends WasmScopeOptions { - /** Scope paths that should emit `ng_scope_on_update` callbacks. */ - watch?: string[]; - /** Emit the current value immediately when registering each watched path. */ +export interface WasmScopeBindingOptions { + /** Scope paths included in `ng_scope_on_transaction` callbacks. */ + watch?: readonly string[]; + /** Emit the current value for each watched path. Defaults to `true`. */ initial?: boolean; } @@ -95,13 +267,6 @@ export interface WasmScopeAbiImports { scope_resolve(namePtr: number, nameLen: number): number; /** Returns a host-owned result buffer handle containing JSON for a scope path. */ scope_get(scopeHandle: number, pathPtr: number, pathLen: number): number; - /** Name-based variant of `scope_get`. */ - scope_get_named( - namePtr: number, - nameLen: number, - pathPtr: number, - pathLen: number, - ): number; /** Writes a JSON payload into a scope path. Returns `1` on success. */ scope_set( scopeHandle: number, @@ -110,49 +275,48 @@ export interface WasmScopeAbiImports { valuePtr: number, valueLen: number, ): number; - /** Name-based variant of `scope_set`. */ - scope_set_named( - namePtr: number, - nameLen: number, + /** Applies an atomic JSON transaction. Returns `1` on success. */ + scope_apply( + scopeHandle: number, + transactionPtr: number, + transactionLen: number, + ): number; + /** Returns a host-owned result buffer containing raw bytes for a scope path. */ + scope_get_binary( + scopeHandle: number, + pathPtr: number, + pathLen: number, + ): number; + /** Writes raw bytes and JSON-encoded write options into a scope path. */ + scope_set_binary( + scopeHandle: number, pathPtr: number, pathLen: number, valuePtr: number, valueLen: number, + optionsPtr: number, + optionsLen: number, ): number; /** Deletes a scope path. Returns `1` on success. */ scope_delete(scopeHandle: number, pathPtr: number, pathLen: number): number; - /** Name-based variant of `scope_delete`. */ - scope_delete_named( - namePtr: number, - nameLen: number, - pathPtr: number, - pathLen: number, - ): number; /** Runs queued Wasm scope bridge callbacks. Returns `1` on success. */ scope_sync(scopeHandle: number): number; - /** Name-based variant of `scope_sync`. */ - scope_sync_named(namePtr: number, nameLen: number): number; /** Watches a scope path and returns a watch handle. */ scope_watch(scopeHandle: number, pathPtr: number, pathLen: number): number; - /** Name-based variant of `scope_watch`. */ - scope_watch_named( - namePtr: number, - nameLen: number, - pathPtr: number, - pathLen: number, - ): number; /** Removes a watch handle. Returns `1` on success. */ scope_unwatch(watchHandle: number): number; /** Unbinds a scope handle without destroying the AngularTS scope. */ scope_unbind(scopeHandle: number): number; - /** Name-based variant of `scope_unbind`. */ - scope_unbind_named(namePtr: number, nameLen: number): number; /** Returns the guest pointer for a host-owned result buffer. */ buffer_ptr(bufferHandle: number): number; /** Returns the byte length for a host-owned result buffer. */ buffer_len(bufferHandle: number): number; /** Releases a host-owned result buffer and its guest-memory allocation. */ buffer_free(bufferHandle: number): void; + /** Returns the last machine-readable guest-call failure. */ + error_code(): WasmAbiErrorCode; + /** Clears the last guest-call failure. */ + error_clear(): void; } /** Full import object returned by `WasmScopeAbi.imports`. */ @@ -161,57 +325,158 @@ export interface WasmScopeAbiImportObject { [WASM_SCOPE_IMPORT_NAMESPACE]: WasmScopeAbiImports; } -export interface WasmInstantiationResult { - instance: WebAssembly.Instance; - exports: WebAssembly.Exports; - module: WebAssembly.Module; +/** High-level WebAssembly host service. */ +export interface WasmService { + /** Loads one module and returns its owned resource. */ + load( + options: WasmLoadOptions, + ): WasmResource; } -/** Callable `$wasm` service plus helpers for the scope ABI. */ -export interface WasmService { - /** Loads a WebAssembly module and returns either exports or the raw result. */ - ( - src: string, - imports?: WebAssembly.Imports, - opts?: WasmOptions, - ): Promise; - /** Wraps an AngularTS scope for direct Wasm client access. */ - scope(scope: ng.Scope, options?: WasmScopeOptions): WasmScope; - /** Creates a language-neutral host ABI for AngularTS scope handles. */ - createScopeAbi(exports?: WasmAbiExports): WasmScopeAbi; -} - -type WasmLoadService = ( - src: string, - imports?: WebAssembly.Imports, - opts?: WasmOptions, -) => Promise; +/** @internal */ +export interface WasmRuntimeState { + readonly appContext: AppContext; + readonly resources: Set; + /** @internal */ + readonly moduleCache: Map; + /** @internal */ + readonly objectModuleCache: WeakMap< + object, + Map + >; + /** @internal */ + readonly moduleEntries: Set; + destroyed: boolean; +} + +interface WasmModuleCacheEntry { + _controller: AbortController; + _promise: Promise; + _references: number; + _settled: boolean; + _remove(): void; +} + +interface WasmCompilationLease { + readonly cacheStatus: WasmCompilationCacheStatus; + readonly module: Promise; + release(): void; +} + +type WasmCompilationCacheStatus = "hit" | "miss" | "shared-pending"; + +type WasmResourceLifecycle = { + status: WasmResourceStatus; +}; interface WasmResultBuffer { _ptr: number; _len: number; } +interface WasmPendingScopeWrite { + _deleted: boolean; + _echo: boolean; + _origin?: string; + _value: unknown; +} + +interface WasmPendingGuestTransaction { + _scopeName: string; + _set: Record; + _delete: Set; + _origins: Set; +} + +interface WasmScopeTransactionRequest extends WasmScopeTransaction { + origin?: string; + echo?: boolean; +} + interface WasmWatchRegistration { - _scope: WasmScope; + _scope: WasmScopeImpl; _dispose: () => void; } +interface WasmScopeRetentionState { + _paused: boolean; + _flushing: boolean; + _pending: WasmScopeQueuedCallback[]; + _deregisterPause: (() => void) | undefined; + _deregisterResume: (() => void) | undefined; + _deregisterDestroy: (() => void) | undefined; +} + +interface WasmScopeQueuedCallback { + _key?: string; + _callback: () => void; +} + +const wasmScopeRetentionStates = new WeakMap< + ng.Scope, + WasmScopeRetentionState +>(); + +function resolveWasmScopeName(scope: ng.Scope, requestedName?: string): string { + return requestedName ?? scope.$scopename ?? String(scope.$id); +} + /** - * Host-side wrapper around one AngularTS scope exposed to Wasm clients. + * Host-side contract for one AngularTS scope exposed to Wasm clients. * * The wrapper mutates the real AngularTS scope. It does not use event bus, * scope-sync, DOM hydration, or object merging. */ -export class WasmScope { - /** Host ABI that owns this handle. */ - readonly abi: WasmScopeAbi; +export interface WasmScope { /** Numeric host handle passed to Wasm clients. */ readonly handle: number; /** Stable scope name exposed over the ABI. */ readonly name: string; - /** Wrapped AngularTS scope. */ - readonly scope: ng.Scope; + /** Whether this wrapper has been disposed. */ + readonly disposed: boolean; + /** Reads a dot-separated path from the wrapped AngularTS scope. */ + get(path: string): unknown; + /** Writes a dot-separated path into the wrapped AngularTS scope. */ + set(path: string, value: unknown, options?: WasmScopeWriteOptions): boolean; + /** Deletes a dot-separated path from the wrapped AngularTS scope. */ + delete(path: string, options?: WasmScopeWriteOptions): boolean; + /** Applies one atomic set/delete transaction. */ + apply( + transaction: WasmScopeTransaction, + options?: WasmScopeWriteOptions, + ): boolean; + /** Reads a scope path as an owned byte array. */ + getBinary(path: string): Uint8Array | undefined; + /** Writes an owned copy of a byte sequence into a scope path. */ + setBinary( + path: string, + value: BufferSource, + options?: WasmScopeWriteOptions, + ): boolean; + /** Runs queued Wasm bridge callbacks for this scope. */ + sync(): void; + /** Registers a callback that runs before this scope syncs. */ + onSync(callback: () => void): () => void; + /** Watches one scope path and returns an operation that removes the watch. */ + watch( + path: string, + callback: (update: WasmScopeUpdate) => void, + options?: WasmScopeWatchOptions, + ): () => void; + /** Binds this scope to guest callbacks and watched paths. */ + bind(options?: WasmScopeBindingOptions): () => void; + /** Disposes ABI bindings without destroying the underlying AngularTS scope. */ + dispose(): void; +} + +/** @internal */ +class WasmScopeImpl implements WasmScope { + readonly handle: number; + readonly name: string; + /** @internal */ + private readonly _abi: WasmScopeAbiImpl; + /** @internal */ + private readonly _scope: ng.Scope; /** @internal */ private readonly _bindings: (() => void)[]; /** @internal */ @@ -220,6 +485,12 @@ export class WasmScope { private _syncScheduled: boolean; /** @internal */ private _destroyed: boolean; + /** @internal */ + private readonly _retentionState: WasmScopeRetentionState; + /** @internal */ + private readonly _pendingWrites = new Map(); + /** @internal */ + private readonly _watchedPaths = new Map(); /** * Creates a host-side Wasm wrapper around an AngularTS scope. @@ -227,20 +498,22 @@ export class WasmScope { * Prefer `WasmScopeAbi.createScope()` so the wrapper is registered with an * ABI handle table immediately. */ + /** @internal */ constructor( - abi: WasmScopeAbi, + abi: WasmScopeAbiImpl, scope: ng.Scope, handle: number, options: WasmScopeOptions = {}, ) { - this.abi = abi; - this.scope = scope; + this._abi = abi; + this._scope = scope; this.handle = handle; - this.name = options.name ?? scope.$scopename ?? String(scope.$id); + this.name = resolveWasmScopeName(scope, options.name); this._bindings = []; this._syncCallbacks = []; this._syncScheduled = false; this._destroyed = false; + this._retentionState = getWasmScopeRetentionState(scope); this._bindings.push( scope.$on("$destroy", () => { this.dispose(); @@ -248,24 +521,111 @@ export class WasmScope { ); } - /** Returns whether the wrapper has been disposed. */ - isDisposed(): boolean { + /** Whether this wrapper has been disposed. */ + get disposed(): boolean { return this._destroyed; } /** Reads a dot-separated path from the wrapped AngularTS scope. */ get(path: string): unknown { - return readScopePath(this.scope, path); + return readScopePath(this._scope, path); } /** Writes a dot-separated path into the wrapped AngularTS scope. */ - set(path: string, value: unknown): boolean { - return writeScopePath(this.scope, path, value); + set( + path: string, + value: unknown, + options: WasmScopeWriteOptions = {}, + ): boolean { + const rollback = this._trackWrite(path, value, options, false); + const written = writeScopePath( + this._scope as unknown as Record, + path, + value, + ); + + if (!written) rollback(); + + return written; } /** Deletes a dot-separated path from the wrapped AngularTS scope. */ - delete(path: string): boolean { - return deleteScopePath(this.scope, path); + delete(path: string, options: WasmScopeWriteOptions = {}): boolean { + const rollback = this._trackWrite(path, undefined, options, true); + const deleted = deleteScopePath( + this._scope as unknown as Record, + path, + ); + + if (!deleted) rollback(); + + return deleted; + } + + /** Applies one atomic set/delete transaction. */ + apply( + transaction: WasmScopeTransaction, + options: WasmScopeWriteOptions = {}, + ): boolean { + const normalized = normalizeWasmScopeTransaction(transaction); + const model = this._scope as Model; + const rollbacks = [ + ...Object.entries(normalized.set).map(([path, value]) => + this._trackWrite(path, value, options, false), + ), + ...normalized.delete.map((path) => + this._trackWrite(path, undefined, options, true), + ), + ]; + + try { + if ( + typeof model.$snapshot === "function" && + typeof model.$restore === "function" + ) { + const snapshot = model.$snapshot(); + + applyWasmScopeTransaction(snapshot, normalized); + model.$restore(snapshot, { + origin: options.origin, + mode: "replace", + }); + } else { + this._scope.$batch(() => { + applyWasmScopeTransaction( + this._scope as unknown as Record, + normalized, + ); + }); + } + } catch (error) { + for (let index = rollbacks.length - 1; index >= 0; index--) { + rollbacks[index](); + } + throw error; + } + + return true; + } + + /** Reads a scope path as an owned byte array. */ + getBinary(path: string): Uint8Array | undefined { + return copyWasmBinaryValue(this.get(path)); + } + + /** Writes an owned copy of a byte sequence into a scope path. */ + setBinary( + path: string, + value: BufferSource, + options: WasmScopeWriteOptions = {}, + ): boolean { + const bytes = copyWasmBinaryValue(value); + + if (!bytes) { + return false; + } + + return this.set(path, bytes, options); } /** Runs queued Wasm bridge callbacks for this scope. */ @@ -328,53 +688,114 @@ export class WasmScope { callback: (update: WasmScopeUpdate) => void, options: WasmScopeWatchOptions = {}, ): () => void { - const dispose = this.scope.$watch( + this._watchedPaths.set(path, (this._watchedPaths.get(path) ?? 0) + 1); + const dispose = this._scope.$watch( path, (value: unknown) => { if (this._destroyed) { return; } - callback({ - scopeHandle: this.handle, - scopeName: this.name, - path, - value, - }); + const pendingWrites = this._pendingWrites.get(path); + const deleted = !hasScopePath(this._scope, path); + let origin: string | undefined; + + if (pendingWrites) { + const pending = pendingWrites.shift(); + + if (pendingWrites.length === 0) this._pendingWrites.delete(path); + origin = pending?._origin; + + if (pending && !pending._echo) { + return; + } + } + + queueScopeAwareWasmCallback( + this._scope, + this._retentionState, + `${String(this.handle)}:${path}`, + () => { + callback({ + scopeHandle: this.handle, + scopeName: this.name, + path, + value, + deleted, + origin, + }); + }, + ); }, !options.initial, ); - if (!dispose) { - return () => { - /* no watch was registered */ - }; - } + let disposed = false; - return dispose; + return () => { + if (disposed) return; + + disposed = true; + dispose?.(); + + const count = (this._watchedPaths.get(path) ?? 1) - 1; + + if (count > 0) { + this._watchedPaths.set(path, count); + } else { + this._watchedPaths.delete(path); + this._pendingWrites.delete(path); + } + }; } - /** - * Binds this scope to Wasm lifecycle exports and optional watched paths. - * - * This is the host-to-guest side of the ABI: AngularTS allocates guest memory - * for callback payloads, invokes the exported callback, and frees memory after - * the callback returns. - */ - bindExports( - exports: WasmAbiExports, - options: WasmScopeBindingOptions = {}, + /** @internal */ + private _trackWrite( + path: string, + value: unknown, + options: WasmScopeWriteOptions, + deleted: boolean, ): () => void { - this.abi.attach(exports); - this.abi.notifyBind(this); + if (!this._watchedPaths.has(path)) { + return () => undefined; + } + if ( + deleted + ? !hasScopePath(this._scope, path) + : hasScopePath(this._scope, path) && + Object.is(readScopePath(this._scope, path), value) + ) { + return () => undefined; + } + + const previous = this._pendingWrites.get(path)?.slice(); + const pendingWrites = this._pendingWrites.get(path) ?? []; + + pendingWrites.push({ + _deleted: deleted, + _echo: options.echo ?? true, + _origin: options.origin, + _value: value, + }); + this._pendingWrites.set(path, pendingWrites); + + return () => { + if (previous) this._pendingWrites.set(path, previous); + else this._pendingWrites.delete(path); + }; + } + + /** Binds this scope to its ABI's guest callbacks and watched paths. */ + bind(options: WasmScopeBindingOptions = {}): () => void { + this._abi._notifyBind(this); const disposers = (options.watch ?? []).map((path) => this.watch( path, (update) => { - this.abi.notifyUpdate(update); + this._abi._queueUpdate(update); }, - { initial: options.initial }, + { initial: options.initial ?? true }, ), ); @@ -390,7 +811,7 @@ export class WasmScope { for (let i = 0, l = disposers.length; i < l; i++) { disposers[i](); } - this.abi.notifyUnbind(this); + this._abi._notifyUnbind(this); }; this._bindings.push(dispose); @@ -413,21 +834,38 @@ export class WasmScope { } this._syncCallbacks.length = 0; + this._pendingWrites.clear(); + this._watchedPaths.clear(); this._syncScheduled = false; - this.abi.unregisterScope(this.handle); + this._abi._unregisterScope(this.handle); } } /** - * Language-neutral AngularTS scope ABI for raw Wasm clients. + * Language-neutral AngularTS scope ABI contract for raw Wasm clients. * * The ABI exchanges strings and JSON-compatible values through guest linear * memory. Guest modules provide `ng_abi_alloc` and `ng_abi_free`; AngularTS uses * those exports whenever it needs to place callback or return payloads in guest * memory. */ -export class WasmScopeAbi { +export interface WasmScopeAbi { /** Import object passed to `WebAssembly.instantiate`. */ + readonly imports: WasmScopeAbiImportObject; + /** True after this ABI and all of its bindings have been disposed. */ + readonly disposed: boolean; + /** Attaches and validates guest exports after instantiation. */ + attach(exports: WebAssembly.Exports): void; + /** Creates and registers a scope wrapper. */ + createScope(scope: ng.Scope, options?: WasmScopeOptions): WasmScope; + /** Returns a previously registered scope wrapper. */ + getScope(reference: WasmScopeReference): WasmScope | undefined; + /** Disposes every scope, watch, and result buffer owned by this ABI. */ + dispose(): void; +} + +/** @internal */ +class WasmScopeAbiImpl implements WasmScopeAbi { readonly imports: WasmScopeAbiImportObject; /** @internal */ private _exports?: WasmAbiExports; @@ -438,78 +876,219 @@ export class WasmScopeAbi { /** @internal */ private _nextWatchHandle = 1; /** @internal */ - private readonly _scopes = new Map(); + private readonly _scopes = new Map(); /** @internal */ - private readonly _scopesByName = new Map(); + private readonly _scopesByName = new Map(); /** @internal */ private readonly _buffers = new Map(); /** @internal */ private readonly _watches = new Map(); + /** @internal */ + private readonly _pendingGuestTransactions = new Map< + number, + WasmPendingGuestTransaction + >(); + /** @internal */ + private _guestTransactionScheduled = false; + /** @internal */ + private _bufferBytes = 0; + /** @internal */ + private _guestCallbackDepth = 0; + /** @internal */ + private _lastError: WasmAbiErrorCode = WasmAbiError.none; + /** @internal */ + private _disposed = false; + + /** @internal */ + private readonly _reportGuestFault: (error: unknown) => void; + /** @internal */ + private readonly _diagnostics: boolean; + /** @internal */ + private readonly _diagnosticSource: WasmSource | undefined; - /** Creates a scope ABI and optionally attaches guest exports immediately. */ - constructor(exports?: WasmAbiExports) { - this._exports = exports; + /** Creates a detached scope ABI whose imports are ready for instantiation. */ + constructor( + reportGuestFault: (error: unknown) => void = rethrowWasmFault, + diagnostics = false, + diagnosticSource?: WasmSource, + ) { + this._reportGuestFault = reportGuestFault; + this._diagnostics = diagnostics; + this._diagnosticSource = diagnosticSource; this.imports = { [WASM_SCOPE_IMPORT_NAMESPACE]: { scope_resolve: (namePtr, nameLen) => - this._scopeResolve(namePtr, nameLen), + this._guardGuestCall( + "scope_resolve", + () => this._scopeResolve(namePtr, nameLen), + 0, + ), scope_get: (scopeHandle, pathPtr, pathLen) => - this._scopeGet(scopeHandle, pathPtr, pathLen), - scope_get_named: (namePtr, nameLen, pathPtr, pathLen) => - this._scopeGetNamed(namePtr, nameLen, pathPtr, pathLen), + this._guardGuestCall( + "scope_get", + () => this._scopeGet(scopeHandle, pathPtr, pathLen), + 0, + ), scope_set: (scopeHandle, pathPtr, pathLen, valuePtr, valueLen) => - this._scopeSet(scopeHandle, pathPtr, pathLen, valuePtr, valueLen), - scope_set_named: ( - namePtr, - nameLen, + this._guardGuestCall( + "scope_set", + () => + this._scopeSet(scopeHandle, pathPtr, pathLen, valuePtr, valueLen), + 0, + ), + scope_apply: (scopeHandle, transactionPtr, transactionLen) => + this._guardGuestCall( + "scope_apply", + () => this._scopeApply(scopeHandle, transactionPtr, transactionLen), + 0, + ), + scope_get_binary: (scopeHandle, pathPtr, pathLen) => + this._guardGuestCall( + "scope_get_binary", + () => this._scopeGetBinary(scopeHandle, pathPtr, pathLen), + 0, + ), + scope_set_binary: ( + scopeHandle, pathPtr, pathLen, valuePtr, valueLen, + optionsPtr, + optionsLen, ) => - this._scopeSetNamed( - namePtr, - nameLen, - pathPtr, - pathLen, - valuePtr, - valueLen, + this._guardGuestCall( + "scope_set_binary", + () => + this._scopeSetBinary( + scopeHandle, + pathPtr, + pathLen, + valuePtr, + valueLen, + optionsPtr, + optionsLen, + ), + 0, ), scope_delete: (scopeHandle, pathPtr, pathLen) => - this._scopeDelete(scopeHandle, pathPtr, pathLen), - scope_delete_named: (namePtr, nameLen, pathPtr, pathLen) => - this._scopeDeleteNamed(namePtr, nameLen, pathPtr, pathLen), - scope_sync: (scopeHandle) => this._scopeSync(scopeHandle), - scope_sync_named: (namePtr, nameLen) => - this._scopeSyncNamed(namePtr, nameLen), + this._guardGuestCall( + "scope_delete", + () => this._scopeDelete(scopeHandle, pathPtr, pathLen), + 0, + ), + scope_sync: (scopeHandle) => + this._guardGuestCall( + "scope_sync", + () => this._scopeSync(scopeHandle), + 0, + ), scope_watch: (scopeHandle, pathPtr, pathLen) => - this._scopeWatch(scopeHandle, pathPtr, pathLen), - scope_watch_named: (namePtr, nameLen, pathPtr, pathLen) => - this._scopeWatchNamed(namePtr, nameLen, pathPtr, pathLen), - scope_unwatch: (watchHandle) => this._scopeUnwatch(watchHandle), - scope_unbind: (scopeHandle) => this._scopeUnbind(scopeHandle), - scope_unbind_named: (namePtr, nameLen) => - this._scopeUnbindNamed(namePtr, nameLen), + this._guardGuestCall( + "scope_watch", + () => this._scopeWatch(scopeHandle, pathPtr, pathLen), + 0, + ), + scope_unwatch: (watchHandle) => + this._guardGuestCall( + "scope_unwatch", + () => this._scopeUnwatch(watchHandle), + 0, + ), + scope_unbind: (scopeHandle) => + this._guardGuestCall( + "scope_unbind", + () => this._scopeUnbind(scopeHandle), + 0, + ), buffer_ptr: (bufferHandle) => - this._buffers.get(bufferHandle)?._ptr ?? 0, + this._guardGuestCall( + "buffer_ptr", + () => this._requireBuffer(bufferHandle)._ptr, + 0, + ), buffer_len: (bufferHandle) => - this._buffers.get(bufferHandle)?._len ?? 0, + this._guardGuestCall( + "buffer_len", + () => this._requireBuffer(bufferHandle)._len, + 0, + ), buffer_free: (bufferHandle) => { - this.freeBuffer(bufferHandle); + this._guardGuestCall( + "buffer_free", + () => { + this._freeBuffer(bufferHandle); + }, + undefined, + ); + }, + error_code: () => this._lastError, + error_clear: () => { + this._lastError = WasmAbiError.none; }, }, }; } - /** Attaches guest exports after instantiation. */ - attach(exports: WasmAbiExports): void { - this._exports = exports; + /** True after this ABI and all of its bindings have been disposed. */ + get disposed(): boolean { + return this._disposed; + } + + /** Attaches and validates guest exports after instantiation. */ + attach(exports: WebAssembly.Exports): void { + if (this._disposed) { + throw new Error("Cannot attach exports to a disposed Wasm scope ABI"); + } + + if (!hasWasmAbiCoreExports(exports)) { + throw new Error( + "WebAssembly module does not export the AngularTS reactive ABI", + ); + } + + const version = readWasmAbiVersion(exports); + + if (version !== WASM_ABI_VERSION) { + throw new WasmAbiVersionError(version); + } + + if (this._exports && this._exports !== exports) { + throw new Error("Wasm scope ABI exports are already attached"); + } + + this._exports = exports as WebAssembly.Exports & WasmAbiExports; } /** Creates and registers a scope wrapper. */ - createScope(scope: ng.Scope, options: WasmScopeOptions = {}): WasmScope { + createScope(scope: ng.Scope, options: WasmScopeOptions = {}): WasmScopeImpl { + if (this._disposed) { + throw new Error("Cannot create a scope from a disposed Wasm scope ABI"); + } + + if (scope.$handler._destroyed) { + throw new Error("Cannot bind a destroyed AngularTS reactive target"); + } + + const name = resolveWasmScopeName(scope, options.name); + + if (name.trim().length === 0) { + throw new Error("Wasm scope name must not be empty"); + } + + if (this._scopesByName.has(name)) { + throw new Error(`Wasm scope name '${name}' is already bound`); + } + + if (this._scopes.size >= MAX_WASM_ABI_SCOPES) { + throw new Error("AngularTS Wasm ABI scope limit exceeded"); + } + const handle = this._nextScopeHandle++; - const wasmScope = new WasmScope(this, scope, handle, options); + const wasmScope = new WasmScopeImpl(this, scope, handle, { + ...options, + name, + }); this._scopes.set(handle, wasmScope); this._scopesByName.set(wasmScope.name, wasmScope); @@ -518,12 +1097,12 @@ export class WasmScopeAbi { } /** Returns a previously registered scope wrapper. */ - getScope(reference: WasmScopeReference): WasmScope | undefined { + getScope(reference: WasmScopeReference): WasmScopeImpl | undefined { return this._resolveScope(reference); } - /** Unregisters a scope wrapper without destroying the AngularTS scope. */ - unregisterScope(handle: number): boolean { + /** @internal */ + _unregisterScope(handle: number): boolean { const scope = this._scopes.get(handle); if (!scope) { @@ -541,11 +1120,13 @@ export class WasmScopeAbi { } } + this._pendingGuestTransactions.delete(handle); + return this._scopes.delete(handle); } - /** Invokes the optional guest bind callback for a scope. */ - notifyBind(scope: WasmScope): void { + /** @internal */ + _notifyBind(scope: WasmScopeImpl): void { const exports = this._requireExports(); if (!exports.ng_scope_on_bind) { @@ -553,52 +1134,151 @@ export class WasmScopeAbi { } this._withGuestString(scope.name, (namePtr, nameLen) => { - exports.ng_scope_on_bind?.(scope.handle, namePtr, nameLen); + this._runGuestCallback("bind", () => { + exports.ng_scope_on_bind?.(scope.handle, namePtr, nameLen); + }); }); } - /** Invokes the optional guest update callback for a watched scope path. */ - notifyUpdate(update: WasmScopeUpdate): void { - const exports = this._requireExports(); + /** @internal */ + _queueUpdate(update: WasmScopeUpdate): void { + let pending = this._pendingGuestTransactions.get(update.scopeHandle); + + if (!pending) { + pending = { + _scopeName: update.scopeName, + _set: Object.create(null) as Record, + _delete: new Set(), + _origins: new Set(), + }; + this._pendingGuestTransactions.set(update.scopeHandle, pending); + } + + if (update.deleted) { + deleteProperty(pending._set, update.path); + pending._delete.add(update.path); + } else { + pending._delete.delete(update.path); + pending._set[update.path] = update.value; + } + pending._origins.add(update.origin); - if (!exports.ng_scope_on_update) { + if (this._guestTransactionScheduled) { return; } - this._withGuestString(update.path, (pathPtr, pathLen) => { - this._withGuestJson(update.value, (valuePtr, valueLen) => { - exports.ng_scope_on_update?.( - update.scopeHandle, - pathPtr, - pathLen, - valuePtr, - valueLen, - ); - }); + this._guestTransactionScheduled = true; + queueMicrotask(() => { + this._guestTransactionScheduled = false; + + if (this._disposed) { + return; + } + + const transactions = Array.from(this._pendingGuestTransactions); + this._pendingGuestTransactions.clear(); + + for (const [scopeHandle, transaction] of transactions) { + this._notifyTransaction(scopeHandle, transaction); + } }); } - /** Invokes the optional guest unbind callback for a scope. */ - notifyUnbind(scope: WasmScope): void { - this._exports?.ng_scope_on_unbind?.(scope.handle); + /** @internal */ + private _notifyTransaction( + scopeHandle: number, + pending: WasmPendingGuestTransaction, + ): void { + const exports = this._requireExports(); + + if (!exports.ng_scope_on_transaction) { + return; + } + + const origins = Array.from(pending._origins); + const origin = origins.length === 1 ? origins[0] : undefined; + const transaction: WasmScopeTransactionRequest = { + set: pending._set, + delete: Array.from(pending._delete), + ...(origin === undefined ? {} : { origin }), + }; + + try { + this._withGuestJson(transaction, (transactionPtr, transactionLen) => { + this._runGuestCallback("transaction", () => { + exports.ng_scope_on_transaction?.( + scopeHandle, + transactionPtr, + transactionLen, + ); + }); + }); + } catch (error) { + const scope = this._scopes.get(scopeHandle); + + scope?.dispose(); + this._reportGuestFault(error); + } + } + + /** @internal */ + _notifyUnbind(scope: WasmScopeImpl): void { + try { + this._runGuestCallback("unbind", () => { + this._exports?.ng_scope_on_unbind?.(scope.handle); + }); + } catch (error) { + queueMicrotask(() => { + this._reportGuestFault(error); + }); + } } - /** Releases one result buffer created by `scope_get`. */ - freeBuffer(bufferHandle: number): void { + /** @internal */ + _freeBuffer(bufferHandle: number): void { const buffer = this._buffers.get(bufferHandle); - if (!buffer) { - return; - } + if (!buffer) throw createWasmGuestError("invalidHandle"); this._buffers.delete(bufferHandle); + this._bufferBytes -= buffer._len; this._requireExports().ng_abi_free(buffer._ptr, buffer._len); } + /** + * Dispose every scope, watch, and result buffer owned by this ABI. + * + * The underlying AngularTS scopes remain alive; only their Wasm bindings are + * released. + */ + dispose(): void { + if (this._disposed) return; + + this._disposed = true; + + for (const scope of Array.from(this._scopes.values())) scope.dispose(); + for (const bufferHandle of Array.from(this._buffers.keys())) { + this._freeBuffer(bufferHandle); + } + + this._watches.clear(); + this._pendingGuestTransactions.clear(); + this._scopes.clear(); + this._scopesByName.clear(); + this._exports = undefined; + } + /** @internal */ private _scopeResolve(namePtr: number, nameLen: number): number { return ( - this._resolveScope(this._readGuestString(namePtr, nameLen))?.handle ?? 0 + this._resolveScope( + this._readGuestString( + namePtr, + nameLen, + MAX_WASM_ABI_PATH_BYTES, + "scope name", + ), + )?.handle ?? 0 ); } @@ -610,114 +1290,165 @@ export class WasmScopeAbi { ) { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; - } + if (!scope) throw createWasmGuestError("invalidHandle"); - const path = this._readGuestString(pathPtr, pathLen); + const path = this._readGuestString( + pathPtr, + pathLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", + ); + assertSafeWasmScopePath(path); return this._createResultBuffer(scope.get(path)); } /** @internal */ - private _scopeGetNamed( - namePtr: number, - nameLen: number, + private _scopeSet( + scopeReference: WasmScopeReference, pathPtr: number, pathLen: number, + valuePtr: number, + valueLen: number, ): number { - return this._scopeGet( - this._readGuestString(namePtr, nameLen), + const scope = this._resolveScope(scopeReference); + + if (!scope) throw createWasmGuestError("invalidHandle"); + + const path = this._readGuestString( pathPtr, pathLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", ); + assertSafeWasmScopePath(path); + const value = this._readGuestJson(valuePtr, valueLen); + const options = normalizeWasmScopeWriteOptions(undefined, false); + + scope.set(path, value, options); + + return 1; } /** @internal */ - private _scopeSet( + private _scopeApply( scopeReference: WasmScopeReference, - pathPtr: number, - pathLen: number, - valuePtr: number, - valueLen: number, + transactionPtr: number, + transactionLen: number, ): number { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; - } + if (!scope) throw createWasmGuestError("invalidHandle"); - const path = this._readGuestString(pathPtr, pathLen); - const value = this._readGuestJson(valuePtr, valueLen); + const request = this._readGuestJson( + transactionPtr, + transactionLen, + ) as WasmScopeTransactionRequest; + const options = normalizeWasmScopeWriteOptions(request, false); + scope.apply(request, options); - return scope.set(path, value) ? 1 : 0; + return 1; } /** @internal */ - private _scopeSetNamed( - namePtr: number, - nameLen: number, + private _scopeGetBinary( + scopeReference: WasmScopeReference, pathPtr: number, pathLen: number, - valuePtr: number, - valueLen: number, ): number { - return this._scopeSet( - this._readGuestString(namePtr, nameLen), + const scope = this._resolveScope(scopeReference); + + if (!scope) throw createWasmGuestError("invalidHandle"); + + const path = this._readGuestString( pathPtr, pathLen, - valuePtr, - valueLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", ); + + assertSafeWasmScopePath(path); + + const bytes = scope.getBinary(path); + + if (!bytes) throw createWasmGuestError("unsupportedValue"); + + return this._createResultBytes(bytes); } /** @internal */ - private _scopeDelete( + private _scopeSetBinary( scopeReference: WasmScopeReference, pathPtr: number, pathLen: number, + valuePtr: number, + valueLen: number, + optionsPtr: number, + optionsLen: number, ): number { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; - } + if (!scope) throw createWasmGuestError("invalidHandle"); + + const path = this._readGuestString( + pathPtr, + pathLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", + ); + assertSafeWasmScopePath(path); + const value = this._readGuestBytes(valuePtr, valueLen).slice(); + const options = normalizeWasmScopeWriteOptions( + optionsLen === 0 + ? undefined + : this._readGuestJson(optionsPtr, optionsLen), + false, + ); + + scope.set(path, value, options); - return scope.delete(this._readGuestString(pathPtr, pathLen)) ? 1 : 0; + return 1; } /** @internal */ - private _scopeDeleteNamed( - namePtr: number, - nameLen: number, + private _scopeDelete( + scopeReference: WasmScopeReference, pathPtr: number, pathLen: number, ): number { - return this._scopeDelete( - this._readGuestString(namePtr, nameLen), + const scope = this._resolveScope(scopeReference); + + if (!scope) throw createWasmGuestError("invalidHandle"); + + const path = this._readGuestString( pathPtr, pathLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", ); + + assertSafeWasmScopePath(path); + + const options = normalizeWasmScopeWriteOptions(undefined, false); + + if (!scope.delete(path, options)) { + throw createWasmGuestError("operationFailed"); + } + + return 1; } /** @internal */ private _scopeSync(scopeReference: WasmScopeReference): number { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; - } + if (!scope) throw createWasmGuestError("invalidHandle"); scope.sync(); return 1; } - /** @internal */ - private _scopeSyncNamed(namePtr: number, nameLen: number): number { - return this._scopeSync(this._readGuestString(namePtr, nameLen)); - } - /** @internal */ private _scopeWatch( scopeReference: WasmScopeReference, @@ -726,14 +1457,22 @@ export class WasmScopeAbi { ): number { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; + if (!scope) throw createWasmGuestError("invalidHandle"); + + if (this._watches.size >= MAX_WASM_ABI_WATCHES) { + throw createWasmGuestError("limitExceeded"); } - const path = this._readGuestString(pathPtr, pathLen); + const path = this._readGuestString( + pathPtr, + pathLen, + MAX_WASM_ABI_PATH_BYTES, + "scope path", + ); + assertSafeWasmScopePath(path); const watchHandle = this._nextWatchHandle++; const dispose = scope.watch(path, (update) => { - this.notifyUpdate(update); + this._queueUpdate(update); }); this._watches.set(watchHandle, { @@ -744,27 +1483,11 @@ export class WasmScopeAbi { return watchHandle; } - /** @internal */ - private _scopeWatchNamed( - namePtr: number, - nameLen: number, - pathPtr: number, - pathLen: number, - ): number { - return this._scopeWatch( - this._readGuestString(namePtr, nameLen), - pathPtr, - pathLen, - ); - } - /** @internal */ private _scopeUnwatch(watchHandle: number): number { const watch = this._watches.get(watchHandle); - if (!watch) { - return 0; - } + if (!watch) throw createWasmGuestError("invalidHandle"); this._watches.delete(watchHandle); watch._dispose(); @@ -776,9 +1499,7 @@ export class WasmScopeAbi { private _scopeUnbind(scopeReference: WasmScopeReference): number { const scope = this._resolveScope(scopeReference); - if (!scope) { - return 0; - } + if (!scope) throw createWasmGuestError("invalidHandle"); scope.dispose(); @@ -786,31 +1507,60 @@ export class WasmScopeAbi { } /** @internal */ - private _scopeUnbindNamed(namePtr: number, nameLen: number): number { - return this._scopeUnbind(this._readGuestString(namePtr, nameLen)); + private _createResultBuffer(value: unknown): number { + return this._createResultBytes( + textEncoder.encode(JSON.stringify(value ?? null)), + ); } /** @internal */ - private _createResultBuffer(value: unknown): number { + private _createResultBytes(bytes: Uint8Array): number { + if (this._buffers.size >= MAX_WASM_ABI_RESULT_BUFFERS) { + throw createWasmGuestError("limitExceeded"); + } + + if (this._bufferBytes + bytes.byteLength > MAX_WASM_ABI_BUFFER_BYTES) { + throw createWasmGuestError("limitExceeded"); + } + const bufferHandle = this._nextBufferHandle++; - const { ptr, len } = this._writeGuestJson(value); + const { ptr, len } = this._writeGuestBytes(bytes); this._buffers.set(bufferHandle, { _ptr: ptr, _len: len, }); + this._bufferBytes += len; return bufferHandle; } /** @internal */ - private _readGuestString(ptr: number, len: number): string { - const { memory } = this._requireExports(); - const bytes = new Uint8Array(memory.buffer, ptr, len); + private _readGuestString( + ptr: number, + len: number, + maximum = MAX_WASM_ABI_PAYLOAD_BYTES, + label = "payload", + ): string { + const bytes = this._readGuestBytes(ptr, len, maximum, label); return textDecoder.decode(bytes); } + /** @internal */ + private _readGuestBytes( + ptr: number, + len: number, + maximum = MAX_WASM_ABI_PAYLOAD_BYTES, + label = "payload", + ): Uint8Array { + const { memory } = this._requireExports(); + + assertWasmMemoryRange(memory, ptr, len, maximum, label); + + return new Uint8Array(memory.buffer, ptr, len); + } + /** @internal */ private _readGuestJson(ptr: number, len: number): unknown { return JSON.parse(this._readGuestString(ptr, len)) as unknown; @@ -823,10 +1573,29 @@ export class WasmScopeAbi { /** @internal */ private _writeGuestString(value: string): { ptr: number; len: number } { + return this._writeGuestBytes(textEncoder.encode(value)); + } + + /** @internal */ + private _writeGuestBytes(bytes: Uint8Array): { ptr: number; len: number } { const exports = this._requireExports(); - const bytes = textEncoder.encode(value); + + if (bytes.byteLength > MAX_WASM_ABI_PAYLOAD_BYTES) { + throw new Error( + `AngularTS Wasm ABI payload exceeds ${String(MAX_WASM_ABI_PAYLOAD_BYTES)} bytes`, + ); + } + const ptr = exports.ng_abi_alloc(bytes.byteLength); + assertWasmMemoryRange( + exports.memory, + ptr, + bytes.byteLength, + MAX_WASM_ABI_PAYLOAD_BYTES, + "guest allocation", + ); + new Uint8Array(exports.memory.buffer, ptr, bytes.byteLength).set(bytes); return { ptr, len: bytes.byteLength }; @@ -870,118 +1639,1366 @@ export class WasmScopeAbi { } /** @internal */ - private _resolveScope(reference: WasmScopeReference): WasmScope | undefined { + private _resolveScope( + reference: WasmScopeReference, + ): WasmScopeImpl | undefined { return isNumber(reference) ? this._scopes.get(reference) : this._scopesByName.get(reference); } -} -export class WasmProvider { - $get = (): WasmService => { - const load: WasmLoadService = async ( - src: string, - imports: WebAssembly.Imports = {}, - opts: WasmOptions = {}, - ) => { - const result = await instantiateWasm(src, imports); - - return opts.raw ? result : result.exports; - }; + /** @internal */ + private _guardGuestCall( + _operation: string, + callback: () => T, + fallback: T, + ): T { + this._lastError = WasmAbiError.none; - return Object.assign(load, { - scope(scope: ng.Scope, options?: WasmScopeOptions): WasmScope { - return new WasmScopeAbi().createScope(scope, options); - }, - createScopeAbi(exports?: WasmAbiExports): WasmScopeAbi { - return new WasmScopeAbi(exports); - }, - }); - }; -} + if (this._disposed) { + this._lastError = WasmAbiError.disposed; -function readScopePath(scope: ng.Scope, path: string): unknown { - if (!path) { - return scope; - } + return fallback; + } - const keys = scopePathKeys(path); + try { + return callback(); + } catch (error) { + this._lastError = classifyWasmGuestError(error); - if (!isSafeScopePath(keys)) { - return undefined; + return fallback; + } } - let current: unknown = scope; + /** @internal */ + private _requireBuffer(bufferHandle: number): WasmResultBuffer { + const buffer = this._buffers.get(bufferHandle); - for (let i = 0, l = keys.length; i < l; i++) { - if (current === null || current === undefined) { - return undefined; - } + if (!buffer) throw createWasmGuestError("invalidHandle"); - current = (current as Record)[keys[i]]; + return buffer; } - return current; -} - -function writeScopePath( - scope: ng.Scope, - path: string, - value: unknown, -): boolean { - const keys = scopePathKeys(path); + /** @internal */ + private _runGuestCallback( + callbackName: "bind" | "transaction" | "unbind", + callback: () => void, + ): void { + if (this._guestCallbackDepth >= MAX_WASM_GUEST_CALLBACK_DEPTH) { + throw new Error("AngularTS Wasm ABI guest callback depth exceeded"); + } - if (keys.length === 0 || !isSafeScopePath(keys)) { - return false; - } + this._guestCallbackDepth++; - let current = scope as Record; + try { + if (!this._diagnostics || !this._diagnosticSource) { + callback(); - for (let i = 0, l = keys.length - 1; i < l; i++) { - const key = keys[i]; - const existing = current[key]; + return; + } - if (existing && typeof existing === "object") { - current = existing as Record; - continue; + const startedAt = nowWasmPerformance(); + + try { + callback(); + } finally { + measureWasmPerformance( + "guest-callback", + startedAt, + true, + this._diagnosticSource, + { callback: callbackName }, + ); + } + } finally { + this._guestCallbackDepth--; } + } +} - const next = Object.create(null) as Record; +/** + * Runtime Wasm ABI namespace for language and runtime binding authors. + * + * Ordinary AngularTS applications should inject `$wasm` and use app-owned + * models for durable state. This object keeps low-level ABI creation grouped + * under the `@angular-wave/angular.ts/services/wasm` subpath so the + * ambient `ng` namespace exposes only ordinary app-facing Wasm types. Type-only + * ABI contracts remain direct exports from this subpath. + */ +export const WasmAbi = Object.freeze({ + /** AngularTS reactive ABI version expected from guest modules. */ + version: WASM_ABI_VERSION, + /** Creates a detached ABI whose imports are ready for guest instantiation. */ + create(): WasmScopeAbi { + return new WasmScopeAbiImpl(); + }, +}); + +class WasmBindingImpl< + TTarget extends WasmTarget = WasmTarget, +> implements WasmBinding { + readonly name: string; + readonly target: TTarget; + /** @internal */ + private readonly _scope: WasmScope; + /** @internal */ + private readonly _onDispose: () => void; + /** @internal */ + private _disposed = false; - if (!writeSafeScopeProperty(current, key, next)) { - return false; - } - current = next; + constructor(scope: WasmScope, target: TTarget, onDispose: () => void) { + this._scope = scope; + this.target = target; + this.name = scope.name; + this._onDispose = onDispose; } - if (!writeSafeScopeProperty(current, keys[keys.length - 1], value)) { - return false; + get disposed(): boolean { + return this._disposed || this._scope.disposed; } - return true; + dispose(): void { + if (this._disposed) return; + + this._disposed = true; + this._scope.dispose(); + this._onDispose(); + } } -function deleteScopePath(scope: ng.Scope, path: string): boolean { - const keys = scopePathKeys(path); +class WasmResourceImpl + implements WasmResource, ScopeProxyBindable +{ + readonly source: WasmSource; + readonly ready: Promise>; + /** @internal */ + private readonly _abi: WasmScopeAbi; + /** @internal */ + private readonly _abortController = new AbortController(); + /** @internal */ + private readonly _bindings = new Set(); + /** @internal */ + private readonly _scopeBindings = new Map(); + /** @internal */ + private readonly _lifecycle: Model; + /** @internal */ + private readonly _onDispose: () => void; + /** @internal */ + private readonly _compilation: WasmCompilationLease; + /** @internal */ + private readonly _diagnostics: boolean; + /** @internal */ + private _status: WasmResourceStatus = "loading"; + /** @internal */ + private _error: WasmError | undefined; + /** @internal */ + private _result: + | { + instance: WebAssembly.Instance; + module: WebAssembly.Module; + exports: TExports; + } + | undefined; - if (keys.length === 0 || !isSafeScopePath(keys)) { - return false; + constructor( + options: WasmLoadOptions, + abi: WasmScopeAbi, + lifecycle: Model, + compilation: WasmCompilationLease, + onDispose: () => void, + ) { + this.source = options.source; + this._abi = abi; + this._lifecycle = lifecycle; + this._compilation = compilation; + this._diagnostics = options.diagnostics === true; + this._onDispose = onDispose; + this.ready = this._load(options); + void this.ready.catch(() => undefined); } - let current = scope as Record; + get status(): WasmResourceStatus { + if (!this._lifecycle.$handler._destroyed) { + void this._lifecycle.status; + } - for (let i = 0, l = keys.length - 1; i < l; i++) { - const next = current[keys[i]]; + return this._status; + } - if (!next || typeof next !== "object") { - return false; + get error(): WasmError | undefined { + if (!this._lifecycle.$handler._destroyed) { + void this._lifecycle.status; } - current = next as Record; + return this._error; } - return deleteProperty(current, keys[keys.length - 1]); -} + get instance(): WebAssembly.Instance { + return this._requireResult().instance; + } + + get module(): WebAssembly.Module { + return this._requireResult().module; + } + + get exports(): TExports { + return this._requireResult().exports; + } + + get disposed(): boolean { + return this._status === "disposed"; + } + + /** @internal */ + [SCOPE_PROXY_BIND](handler: Scope): void { + if (!handler._destroyed) { + this._scopeBindings.set(handler.$id, handler); + } + } + + async bind( + target: TTarget, + options: WasmBindingOptions = {}, + ): Promise> { + const startedAt = nowWasmPerformance(); + + if (this.disposed) { + throw this._disposedError("Cannot bind a disposed WebAssembly resource"); + } + + if (target.$handler._destroyed) { + throw new WasmError( + "binding", + "Cannot bind a destroyed AngularTS reactive target", + { source: this.source, stage: "bind" }, + ); + } + + if (options.name?.trim().length === 0) { + throw new WasmError("binding", "Wasm scope name must not be empty", { + source: this.source, + stage: "bind", + }); + } + + let removeDestroyListener!: () => void; + const targetDestroyed = new Promise((_resolve, reject) => { + removeDestroyListener = target.$on("$destroy", () => { + reject( + new WasmError( + "binding", + "Cannot bind a destroyed AngularTS reactive target", + { source: this.source, stage: "bind" }, + ), + ); + }); + }); + + try { + return await Promise.race([ + this.ready.then(() => this._bindReadyTarget(target, options)), + targetDestroyed, + ]); + } finally { + removeDestroyListener(); + measureWasmPerformance("bind", startedAt, this._diagnostics, this.source); + } + } + + dispose(): void { + if (this.disposed) return; + + this._setStatus("disposed"); + this._abortController.abort(); + this._compilation.release(); + + for (const binding of Array.from(this._bindings)) binding.dispose(); + + this._bindings.clear(); + this._scheduleLifecycleBindings(["disposed", "status"]); + this._scopeBindings.clear(); + this._abi.dispose(); + this._result = undefined; + this._onDispose(); + } + + /** @internal */ + private async _load( + options: WasmLoadOptions, + ): Promise> { + const loadStartedAt = nowWasmPerformance(); + let stage: WasmErrorStage = "compile"; + + try { + const compileStartedAt = nowWasmPerformance(); + const module = await waitForWasmCompilation( + this._compilation.module, + this._abortController.signal, + ); + measureWasmPerformance( + "compile", + compileStartedAt, + this._diagnostics, + this.source, + { cacheStatus: this._compilation.cacheStatus }, + ); + + stage = "link"; + const instantiateStartedAt = nowWasmPerformance(); + const instance = await WebAssembly.instantiate( + module, + mergeWasmImports(options.imports, this._abi.imports), + ); + measureWasmPerformance( + "instantiate", + instantiateStartedAt, + this._diagnostics, + this.source, + ); + + if (this.disposed) { + throw this._disposedError( + "WebAssembly resource was disposed while loading", + ); + } + + const exports = instance.exports as TExports; + + this._result = { instance, module, exports }; + + if (hasWasmAbiCoreExports(instance.exports)) { + this._abi.attach(instance.exports); + } + + this._setStatus("ready"); + measureWasmPerformance( + "load", + loadStartedAt, + this._diagnostics, + this.source, + { status: "ready" }, + ); + + return this; + } catch (cause) { + if (this.disposed) { + throw cause instanceof WasmError + ? cause + : this._disposedError( + "WebAssembly resource was disposed while loading", + cause, + ); + } + + const error = + cause instanceof WasmError + ? cause + : cause instanceof WasmAbiVersionError + ? new WasmError("unsupported-abi", cause.message, { + cause, + source: options.source, + }) + : createWasmLoadError(cause, stage, options.source); + + this._setError(error); + this._setStatus("error"); + this._abi.dispose(); + measureWasmPerformance( + "load", + loadStartedAt, + this._diagnostics, + this.source, + { status: "error" }, + ); + + throw error; + } finally { + this._compilation.release(); + } + } + + /** @internal */ + private _bindReadyTarget( + target: TTarget, + options: WasmBindingOptions, + ): WasmBinding { + if (this.disposed) { + throw this._disposedError("Cannot bind a disposed WebAssembly resource"); + } + + if (!isWasmAbiExports(this.exports)) { + throw new WasmError( + "unsupported-abi", + "WebAssembly module does not export the AngularTS reactive ABI", + { source: this.source, stage: "bind" }, + ); + } + + let scope: WasmScope | undefined; + + try { + scope = this._abi.createScope(target, { name: options.name }); + scope.bind({ + watch: options.watch, + initial: options.initial, + }); + + const binding = new WasmBindingImpl(scope, target, () => { + this._bindings.delete(binding); + }); + this._bindings.add(binding); + + return binding; + } catch (cause) { + scope?.dispose(); + + if (cause instanceof WasmError) throw cause; + + throw new WasmError( + "binding", + cause instanceof Error + ? cause.message + : "WebAssembly target binding failed", + { + cause, + source: this.source, + stage: "bind", + }, + ); + } + } + + /** @internal */ + private _requireResult(): NonNullable["_result"]> { + if (this._result) return this._result; + + if (this.disposed) { + throw this._disposedError("WebAssembly resource has been disposed"); + } + + throw ( + this._error ?? + new WasmError("load", "WebAssembly resource is still loading", { + source: this.source, + }) + ); + } + + /** @internal */ + private _disposedError(message: string, cause?: unknown): WasmError { + return new WasmError("disposed", message, { + cause, + source: this.source, + }); + } + + /** @internal */ + private _setStatus(status: WasmResourceStatus): void { + this._status = status; + + if (!this._lifecycle.$handler._destroyed) { + this._lifecycle.status = status; + } + + this._scheduleLifecycleBindings(["status", "disposed"]); + } + + /** @internal */ + private _setError(error: WasmError): void { + this._error = error; + this._scheduleLifecycleBindings(["error"]); + } + + /** @internal */ + private _scheduleLifecycleBindings(keys: string[]): void { + for (const [scopeId, handler] of this._scopeBindings) { + if (handler._destroyed) { + this._scopeBindings.delete(scopeId); + + continue; + } + + handler._scheduleWatchKeys(keys); + } + } +} + +/** @internal */ +export function createWasmRuntimeState( + appContext: AppContext, +): WasmRuntimeState { + return { + appContext, + resources: new Set(), + moduleCache: new Map(), + objectModuleCache: new WeakMap(), + moduleEntries: new Set(), + destroyed: false, + }; +} + +/** @internal */ +export function destroyWasmRuntimeState(state: WasmRuntimeState): void { + if (state.destroyed) return; + + state.destroyed = true; + + for (const resource of Array.from(state.resources)) resource.dispose(); + + for (const entry of state.moduleEntries) { + if (!entry._settled) entry._controller.abort(); + } + + state.resources.clear(); + state.moduleEntries.clear(); + state.moduleCache.clear(); +} + +/** @internal */ +export function createWasmService(state: WasmRuntimeState): WasmService { + const assertActive = (): void => { + if (state.destroyed) { + throw new WasmError( + "disposed", + "Cannot use $wasm after runtime teardown", + ); + } + }; + + return { + load( + options: WasmLoadOptions, + ): WasmResource { + assertActive(); + + const abi = new WasmScopeAbiImpl( + (error) => { + state.appContext._reportModelException(error); + }, + options.diagnostics === true, + options.source, + ); + const lifecycle = state.appContext.createReactive({ + status: "loading", + }); + const compilation = acquireWasmCompilation(state, options); + const resource: WasmResource = new WasmResourceImpl( + options, + abi, + lifecycle, + compilation, + () => { + state.resources.delete(resource); + state.appContext._releaseReactive(lifecycle); + }, + ); + state.resources.add(resource); + return resource; + }, + }; +} + +function acquireWasmCompilation( + state: WasmRuntimeState, + options: WasmLoadOptions, +): WasmCompilationLease { + const compileOptions = snapshotWasmCompileOptions(options.compile); + const location = getWasmModuleCacheLocation( + state, + options.source, + compileOptions, + ); + const existing = location?.get(); + + if (existing) { + existing._references++; + + return createWasmCompilationLease( + existing, + existing._settled ? "hit" : "shared-pending", + ); + } + + const controller = new AbortController(); + const promise = compileWasm( + options.source, + controller.signal, + compileOptions, + ); + const entry: WasmModuleCacheEntry = { + _controller: controller, + _promise: promise, + _references: 1, + _settled: false, + _remove: () => { + location?.delete(entry); + state.moduleEntries.delete(entry); + }, + }; + state.moduleEntries.add(entry); + location?.set(entry); + void promise.then( + () => { + entry._settled = true; + state.moduleEntries.delete(entry); + trimWasmModuleCache(state); + + return undefined; + }, + () => { + entry._settled = true; + entry._remove(); + + return undefined; + }, + ); + + return createWasmCompilationLease(entry, "miss"); +} + +function createWasmCompilationLease( + entry: WasmModuleCacheEntry, + cacheStatus: WasmCompilationCacheStatus, +): WasmCompilationLease { + let released = false; + + return { + cacheStatus, + module: entry._promise, + release(): void { + if (released) return; + + released = true; + entry._references--; + + if (entry._references === 0 && !entry._settled) { + entry._remove(); + entry._controller.abort(); + } + }, + }; +} + +interface WasmModuleCacheLocation { + delete(entry: WasmModuleCacheEntry): void; + get(): WasmModuleCacheEntry | undefined; + set(entry: WasmModuleCacheEntry): void; +} + +function getWasmModuleCacheLocation( + state: WasmRuntimeState, + source: WasmSource, + compileOptions: WasmCompileOptions | undefined, +): WasmModuleCacheLocation | undefined { + const compileKey = serializeWasmCompileOptions(compileOptions); + + if (typeof source === "string" || source instanceof URL) { + const sourceKey = normalizeWasmSourceUrl(source); + const key = `${sourceKey}\u0000${compileKey}`; + + return { + get: () => { + const entry = state.moduleCache.get(key); + + if (entry?._settled) { + state.moduleCache.delete(key); + state.moduleCache.set(key, entry); + } + + return entry; + }, + set: (entry) => { + state.moduleCache.set(key, entry); + trimWasmModuleCache(state); + }, + delete: (entry) => { + if (state.moduleCache.get(key) === entry) state.moduleCache.delete(key); + }, + }; + } + + if ( + (source instanceof Request && + source.cache !== "no-store" && + source.cache !== "reload") || + source instanceof Response + ) { + let bucket = state.objectModuleCache.get(source); + + if (!bucket) { + bucket = new Map(); + state.objectModuleCache.set(source, bucket); + } + + return { + get: () => bucket.get(compileKey), + set: (entry) => bucket.set(compileKey, entry), + delete: (entry) => { + if (bucket.get(compileKey) === entry) bucket.delete(compileKey); + }, + }; + } + + return undefined; +} + +function trimWasmModuleCache(state: WasmRuntimeState): void { + if (state.moduleCache.size <= MAX_WASM_MODULE_CACHE_ENTRIES) return; + + for (const [key, entry] of state.moduleCache) { + if (state.moduleCache.size <= MAX_WASM_MODULE_CACHE_ENTRIES) return; + if (!entry._settled) continue; + + state.moduleCache.delete(key); + } +} + +function normalizeWasmSourceUrl(source: string | URL): string { + try { + return new URL(String(source), globalThis.location.href).href; + } catch { + return String(source); + } +} + +function serializeWasmCompileOptions( + options: WasmCompileOptions | undefined, +): string { + if (options === undefined) return "default"; + + return JSON.stringify({ + builtins: options.builtins ?? [], + importedStringConstants: options.importedStringConstants ?? null, + }); +} + +function snapshotWasmCompileOptions( + options: WasmCompileOptions | undefined, +): WasmCompileOptions | undefined { + if (options === undefined) return undefined; + + const snapshot: WasmCompileOptions = {}; + + if (options.builtins !== undefined) { + snapshot.builtins = Object.freeze(Array.from(options.builtins)); + } + if (options.importedStringConstants !== undefined) { + snapshot.importedStringConstants = options.importedStringConstants; + } + + return Object.freeze(snapshot); +} + +function waitForWasmCompilation( + compilation: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => { + reject(new DOMException("The operation was aborted", "AbortError")); + }; + const settle = (): void => { + signal.removeEventListener("abort", abort); + }; + + signal.addEventListener("abort", abort, { once: true }); + void compilation.then( + (module) => { + settle(); + resolve(module); + + return undefined; + }, + (error: unknown) => { + settle(); + reject( + error instanceof Error + ? error + : new Error("WebAssembly compilation failed", { cause: error }), + ); + + return undefined; + }, + ); + }); +} + +function nowWasmPerformance(): number { + return globalThis.performance.now(); +} + +function measureWasmPerformance( + phase: "bind" | "compile" | "guest-callback" | "instantiate" | "load", + startedAt: number, + enabled: boolean, + source: WasmSource, + detail: Record = {}, +): void { + if (!enabled) return; + + try { + globalThis.performance.measure(`angular.ts:wasm:${phase}`, { + start: startedAt, + end: nowWasmPerformance(), + detail: { + ...detail, + source: describeWasmSource(source), + }, + }); + } catch { + // Performance diagnostics must never affect resource behavior. + } +} + +function describeWasmSource(source: WasmSource): string { + if (typeof source === "string" || source instanceof URL) + return String(source); + if (source instanceof Request) return source.url; + if (source instanceof Response) return source.url || "Response"; + if (source instanceof WebAssembly.Module) return "WebAssembly.Module"; + + return "BufferSource"; +} + +function mergeWasmImports( + imports: WebAssembly.Imports = {}, + abiImports: WasmScopeAbiImportObject, +): WebAssembly.Imports { + const angularImports = imports[WASM_SCOPE_IMPORT_NAMESPACE] ?? {}; + const reservedImports = abiImports[WASM_SCOPE_IMPORT_NAMESPACE]; + + for (const name of Object.keys(reservedImports)) { + if (Object.prototype.hasOwnProperty.call(angularImports, name)) { + throw new Error( + `WebAssembly import '${WASM_SCOPE_IMPORT_NAMESPACE}.${name}' is reserved by AngularTS`, + ); + } + } + + return { + ...imports, + [WASM_SCOPE_IMPORT_NAMESPACE]: { + ...angularImports, + ...reservedImports, + }, + }; +} + +function classifyWasmErrorStage( + cause: unknown, + stage: "compile" | "link", + source: WasmSource, +): WasmErrorStage { + if (stage === "link") { + return cause instanceof WebAssembly.RuntimeError ? "start" : "link"; + } + + if ( + cause instanceof Error && + (cause.message.startsWith("WebAssembly fetch failed") || + (cause instanceof TypeError && + (typeof source === "string" || + source instanceof URL || + source instanceof Request))) + ) { + return "fetch"; + } + + return "compile"; +} + +function createWasmLoadError( + cause: unknown, + stage: "compile" | "link", + source: WasmSource, +): WasmError { + const failureStage = classifyWasmErrorStage(cause, stage, source); + const detail = cause instanceof Error ? `: ${cause.message}` : ""; + + return new WasmError( + "load", + `WebAssembly module failed during ${failureStage}${detail}`, + { + cause, + source, + stage: failureStage, + }, + ); +} + +function isWasmAbiExports( + exports: WebAssembly.Exports, +): exports is WebAssembly.Exports & WasmAbiExports { + return ( + hasWasmAbiCoreExports(exports) && + readWasmAbiVersion(exports) === WASM_ABI_VERSION + ); +} + +function hasWasmAbiCoreExports( + exports: WebAssembly.Exports, +): exports is WebAssembly.Exports & + Omit & + Partial> { + return ( + isWasmMemoryView(exports.memory) && + typeof exports.ng_abi_alloc === "function" && + typeof exports.ng_abi_free === "function" + ); +} + +function readWasmAbiVersion( + exports: Partial>, +): number { + if (typeof exports.ng_abi_version !== "function") { + return -1; + } + + const version = exports.ng_abi_version(); + + return Number.isSafeInteger(version) ? version : -1; +} + +function assertWasmMemoryRange( + memory: Pick, + ptr: number, + len: number, + maximum: number, + label: string, +): void { + if (!Number.isSafeInteger(ptr) || ptr < 0) { + throw new RangeError(`Invalid AngularTS Wasm ABI ${label} pointer`); + } + + if (!Number.isSafeInteger(len) || len < 0 || len > maximum) { + throw new RangeError(`Invalid AngularTS Wasm ABI ${label} length`); + } + + if (ptr === 0 && len > 0) { + throw new RangeError(`Invalid AngularTS Wasm ABI ${label} pointer`); + } + + if (ptr > memory.buffer.byteLength || len > memory.buffer.byteLength - ptr) { + throw new RangeError(`AngularTS Wasm ABI ${label} exceeds guest memory`); + } +} + +function isWasmMemoryView( + value: unknown, +): value is Pick { + try { + const buffer = (value as { buffer?: unknown } | null)?.buffer; + + return ( + buffer instanceof ArrayBuffer || + (typeof SharedArrayBuffer === "function" && + buffer instanceof SharedArrayBuffer) + ); + } catch { + return false; + } +} + +class WasmAbiVersionError extends Error { + constructor(version: number) { + super( + `Unsupported AngularTS Wasm ABI version ${String(version)}; expected ${String(WASM_ABI_VERSION)}`, + ); + this.name = "WasmAbiVersionError"; + } +} + +class WasmGuestCallError extends Error { + readonly code: WasmAbiErrorCode; + + constructor(code: WasmAbiErrorCode, message: string) { + super(message); + this.name = "WasmGuestCallError"; + this.code = code; + } +} + +function createWasmGuestError( + name: Exclude, +): WasmGuestCallError { + return new WasmGuestCallError( + WasmAbiError[name], + `AngularTS Wasm ABI ${name.replace(/[A-Z]/g, (value) => `-${value.toLowerCase()}`)}`, + ); +} + +function classifyWasmGuestError(error: unknown): WasmAbiErrorCode { + if (error instanceof WasmGuestCallError) { + return error.code; + } + + if (error instanceof SyntaxError) { + return WasmAbiError.invalidJson; + } + + if (error instanceof RangeError) { + return error.message.includes("length") + ? WasmAbiError.invalidLength + : WasmAbiError.invalidPointer; + } + + return WasmAbiError.operationFailed; +} + +function normalizeWasmScopeWriteOptions( + value: unknown, + defaultEcho: boolean, +): WasmScopeWriteOptions { + if (value === undefined) { + return { + origin: WASM_SCOPE_DEFAULT_ORIGIN, + echo: defaultEcho, + }; + } + + if (!isPlainWasmRecord(value)) { + throw createWasmGuestError("invalidTransaction"); + } + + const origin = value.origin; + const echo = value.echo; + + if (origin !== undefined && typeof origin !== "string") { + throw createWasmGuestError("invalidTransaction"); + } + if (echo !== undefined && typeof echo !== "boolean") { + throw createWasmGuestError("invalidTransaction"); + } + if ( + typeof origin === "string" && + textEncoder.encode(origin).byteLength > MAX_WASM_ABI_PATH_BYTES + ) { + throw createWasmGuestError("invalidLength"); + } + + return { + origin: origin ?? WASM_SCOPE_DEFAULT_ORIGIN, + echo: echo ?? defaultEcho, + }; +} + +function normalizeWasmScopeTransaction(transaction: WasmScopeTransaction): { + set: Record; + delete: string[]; +} { + if (!isPlainWasmRecord(transaction)) { + throw createWasmGuestError("invalidTransaction"); + } + + const inputSet = transaction.set ?? {}; + const inputDelete = transaction.delete ?? []; + + if (!isPlainWasmRecord(inputSet) || !Array.isArray(inputDelete)) { + throw createWasmGuestError("invalidTransaction"); + } + + const set = Object.create(null) as Record; + const deleted = new Set(); + + for (const path of Object.keys(inputSet)) { + assertSafeWasmScopePath(path); + set[path] = inputSet[path]; + } + + for (const path of inputDelete) { + if (typeof path !== "string") { + throw createWasmGuestError("invalidTransaction"); + } + + assertSafeWasmScopePath(path); + + if (Object.prototype.hasOwnProperty.call(set, path) || deleted.has(path)) { + throw createWasmGuestError("invalidTransaction"); + } + + deleted.add(path); + } + + if (Object.keys(set).length === 0 && deleted.size === 0) { + throw createWasmGuestError("invalidTransaction"); + } + + return { set, delete: Array.from(deleted) }; +} + +function applyWasmScopeTransaction( + target: Record, + transaction: { set: Record; delete: string[] }, +): void { + for (const [path, value] of Object.entries(transaction.set)) { + writeScopePath(target, path, value); + } + + for (const path of transaction.delete) { + deleteScopePath(target, path); + } +} + +function assertSafeWasmScopePath(path: string): void { + const keys = scopePathKeys(path); + + if (keys.length === 0) { + throw createWasmGuestError("invalidTransaction"); + } + if (!isSafeScopePath(keys)) { + throw createWasmGuestError("unsafePath"); + } +} + +function isPlainWasmRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Reflect.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +} + +function copyWasmBinaryValue(value: unknown): Uint8Array | undefined { + if ( + value instanceof ArrayBuffer || + (typeof SharedArrayBuffer === "function" && + value instanceof SharedArrayBuffer) + ) { + return new Uint8Array(value).slice(); + } + + if (ArrayBuffer.isView(value)) { + return new Uint8Array( + value.buffer, + value.byteOffset, + value.byteLength, + ).slice(); + } + + return undefined; +} + +function rethrowWasmFault(error: unknown): never { + throw error; +} + +function getWasmScopeRetentionState(scope: ng.Scope): WasmScopeRetentionState { + let state = wasmScopeRetentionStates.get(scope); + + if (state) return state; + + state = { + _paused: false, + _flushing: false, + _pending: [], + _deregisterPause: undefined, + _deregisterResume: undefined, + _deregisterDestroy: undefined, + }; + + state._deregisterPause = scope.$on("$viewRetentionPause", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + state._paused = true; + }); + + state._deregisterResume = scope.$on("$viewRetentionResume", (...args) => { + if (!shouldHandleViewRetentionPause(args, "schedulers")) { + return; + } + + if (!state._paused) { + return; + } + + state._paused = false; + flushWasmScopeQueue(state); + }); + + state._deregisterDestroy = scope.$on("$destroy", () => { + state._pending.length = 0; + state._flushing = false; + state._deregisterPause?.(); + state._deregisterResume?.(); + state._deregisterDestroy?.(); + wasmScopeRetentionStates.delete(scope); + }); + + wasmScopeRetentionStates.set(scope, state); + + return state; +} + +function queueScopeAwareWasmCallback( + scope: ng.Scope, + state: WasmScopeRetentionState, + key: string | undefined, + callback: () => void, +): void { + if (state._paused) { + const pending: WasmScopeQueuedCallback = { + _key: key, + _callback: callback, + }; + + if (key) { + for (let i = state._pending.length - 1; i >= 0; i--) { + if (state._pending[i]._key === key) { + state._pending[i] = pending; + flushWasmScopeQueue(state); + + return; + } + } + } + + state._pending.push(pending); + flushWasmScopeQueue(state); + + return; + } + + callback(); +} + +function flushWasmScopeQueue(state: WasmScopeRetentionState): void { + if (state._flushing || state._paused || state._pending.length === 0) { + return; + } + + state._flushing = true; + + queueMicrotask(() => { + state._flushing = false; + + /* istanbul ignore if -- microtask interleaving is covered by retention behavior tests. */ + if (state._paused) { + return; + } + + const pending = state._pending.splice(0); + + for (let i = 0; i < pending.length; i++) { + pending[i]._callback(); + } + }); +} + +function readScopePath(scope: ng.Scope, path: string): unknown { + if (!path) { + return scope; + } + + const keys = scopePathKeys(path); + + if (!isSafeScopePath(keys)) { + return undefined; + } + + let current: unknown = scope; + + for (let i = 0, l = keys.length; i < l; i++) { + if (current === null || current === undefined) { + return undefined; + } + + current = (current as Record)[keys[i]]; + } + + return current; +} + +function hasScopePath(scope: ng.Scope, path: string): boolean { + if (!path) { + return true; + } + + const keys = scopePathKeys(path); + + if (!isSafeScopePath(keys)) { + return false; + } + + let current: unknown = scope; + + for (const key of keys) { + if ( + (typeof current !== "object" || current === null) && + typeof current !== "function" + ) { + return false; + } + if (!(key in current)) { + return false; + } + + current = (current as Record)[key]; + } + + return true; +} + +function writeScopePath( + scope: Record, + path: string, + value: unknown, +): boolean { + const keys = scopePathKeys(path); + + if (keys.length === 0 || !isSafeScopePath(keys)) { + return false; + } + + let current = scope; + + for (let i = 0, l = keys.length - 1; i < l; i++) { + const key = keys[i]; + const existing = current[key]; + + if (existing && typeof existing === "object") { + current = existing as Record; + continue; + } + + const next = Object.create(null) as Record; + + writeSafeScopeProperty(current, key, next); + current = next; + } + + writeSafeScopeProperty(current, keys[keys.length - 1], value); + + return true; +} + +function deleteScopePath( + scope: Record, + path: string, +): boolean { + const keys = scopePathKeys(path); + + if (keys.length === 0 || !isSafeScopePath(keys)) { + return false; + } + + let current = scope; + + for (let i = 0, l = keys.length - 1; i < l; i++) { + const next = current[keys[i]]; + + if (!next || typeof next !== "object") { + return false; + } + + current = next as Record; + } + + return deleteProperty(current, keys[keys.length - 1]); +} function scopePathKeys(path: string): string[] { return path.split(".").filter(Boolean); @@ -999,13 +3016,11 @@ function writeSafeScopeProperty( target: Record, key: string, value: unknown, -): boolean { - if (isUnsafeScopePathKey(key)) { - return false; - } +): void { + if (isProxy(target) || ("$handler" in target && "$target" in target)) { + target[key] = value; - if (isProxy(target)) { - return target.$handler.set(target.$target, key, value, target); + return; } Object.defineProperty(target, key, { @@ -1014,6 +3029,4 @@ function writeSafeScopeProperty( value, writable: true, }); - - return true; } diff --git a/src/services/web-component/README.md b/src/services/web-component/README.md new file mode 100644 index 000000000..3a3426957 --- /dev/null +++ b/src/services/web-component/README.md @@ -0,0 +1,43 @@ +# Web Component Service + +`$webComponent` defines native custom elements backed by AngularTS scopes and +compiled templates. `NgModule.appComponent(...)` and +`NgModule.webComponent(...)` are the normal declaration APIs. + +## Configuration + +Configure defaults before bootstrap with the typed module configuration API: + +```ts +app.config({ + $webComponent: { + defaults: { + shadow: true, + isolate: true, + }, + }, +}); +``` + +Declaration options override these defaults. + +## Runtime Ownership + +The owning runtime tracks custom-element scopes, disconnect timers, compiled +content, callbacks, and constructor metadata. Runtime teardown releases those +resources and rejects later service operations. + +The Custom Elements API cannot unregister a tag name. Registered constructors +therefore remain in the browser registry after teardown, but AngularTS removes +their injector/compiler metadata so they no longer retain the destroyed +runtime or reconnect scopes. + +For custom runtimes, include `webComponentModule` from +`@angular-wave/angular.ts/runtime/web-component`. `defineAngularElement(...)` +includes that registrar automatically. + +## Tests + +`web-component.spec.ts` covers scope inheritance, inputs, events, native +`ScopeElement` classes, typed defaults, disconnect behavior, and runtime +teardown. Browser tests cover standalone, React-hosted, and microapp examples. diff --git a/src/services/web-component/web-component-microapp-demo.js b/src/services/web-component/web-component-microapp-demo.js index fdcd83bde..17a889fb6 100644 --- a/src/services/web-component/web-component-microapp-demo.js +++ b/src/services/web-component/web-component-microapp-demo.js @@ -28,15 +28,13 @@ class InventoryStore { /** @type {import("../../runtime/web-component.ts").AngularElementOptions} */ const inventoryMicroapp = { - ngModule: { - directives: { - ngClass: classDirective, - ngClick: ngClickDirective, - ngRepeat: ngRepeatDirective, - }, - services: { - inventoryStore: InventoryStore, - }, + directives: { + ngClass: classDirective, + ngClick: ngClickDirective, + ngRepeat: ngRepeatDirective, + }, + services: { + inventoryStore: InventoryStore, }, component: { shadow: true, diff --git a/src/services/web-component/web-component-react-demo.html b/src/services/web-component/web-component-react-demo.html index ec28eb74a..065404820 100644 --- a/src/services/web-component/web-component-react-demo.html +++ b/src/services/web-component/web-component-react-demo.html @@ -13,11 +13,9 @@ import { defineAngularElement } from "/src/runtime/web-component.ts"; defineAngularElement("aw-status-card", { - ngModule: { - directives: { - ngClass: classDirective, - ngClick: ngClickDirective, - }, + directives: { + ngClass: classDirective, + ngClick: ngClickDirective, }, component: { shadow: true, diff --git a/src/services/web-component/web-component.spec.ts b/src/services/web-component/web-component.spec.ts index f5483f40a..c1aa50e81 100644 --- a/src/services/web-component/web-component.spec.ts +++ b/src/services/web-component/web-component.spec.ts @@ -3,7 +3,12 @@ import { Angular } from "../../angular.ts"; import { dealoc, getScope } from "../../shared/dom.ts"; import { wait } from "../../shared/test-utils.ts"; -import { ScopeElement } from "./web-component.ts"; +import { + createWebComponentRuntimeState, + createWebComponentService, + destroyWebComponentRuntimeState, + ScopeElement, +} from "./web-component.ts"; let nextId = 0; @@ -94,6 +99,172 @@ describe("$webComponent", () => { }); }); + it("creates detached isolate element scopes with the root as parent", () => { + const angular = new Angular(); + + angular.bootstrap(app, []).invoke(($webComponent, $rootScope) => { + const host = document.createElement("section"); + const scope = $webComponent.createElementScope( + host, + { title: "isolated" }, + { isolate: true }, + ); + + expect(scope.title).toBe("isolated"); + expect(scope.$parent.$id).toBe($rootScope.$id); + }); + }); + + it("applies typed app-wide defaults to app components", async () => { + const tagName = nextTagName("default-shadow-card"); + const moduleName = nextModuleName(); + const angular = new Angular(); + + angular + .module(moduleName, []) + .config({ + $webComponent: { + defaults: { + shadow: true, + }, + }, + }) + .appComponent(tagName, { + scope: { title: "configured" }, + template: "{{title}}", + }); + + angular.bootstrap(app, [moduleName]); + + const element = document.createElement(tagName); + + app.appendChild(element); + await wait(); + await wait(); + + expect(element.shadowRoot?.textContent).toContain("configured"); + }); + + it("releases runtime-owned custom element state during teardown", async () => { + const tagName = nextTagName("runtime-owned-card"); + const angular = new Angular(); + let injector; + let rootScope; + let compile; + + angular + .bootstrap(app, []) + .invoke((_$injector_, _$rootScope_, _$compile_) => { + injector = _$injector_; + rootScope = _$rootScope_; + compile = _$compile_; + }); + + const state = createWebComponentRuntimeState(); + const service = createWebComponentService( + injector, + rootScope, + compile, + state, + ); + const events = []; + + service.defineAppComponent(tagName, { + shadow: true, + inputs: { title: String }, + template: "runtime owned", + connected() { + return () => events.push("cleanup"); + }, + disconnected() { + events.push("disconnected"); + }, + }); + + const element = document.createElement(tagName); + + app.appendChild(element); + await wait(); + await wait(); + + const scope = getScope(element); + + destroyWebComponentRuntimeState(state); + destroyWebComponentRuntimeState(state); + + expect(events).toEqual(["cleanup", "disconnected"]); + expect(scope.$handler._destroyed).toBeTrue(); + expect(element.shadowRoot.textContent).toBe(""); + expect(() => + service.createElementScope(document.createElement("div")), + ).toThrowError("Cannot use $webComponent after runtime teardown"); + + element.setAttribute("title", "stale"); + element.remove(); + app.appendChild(element); + await wait(); + + expect(element.scope).toBe(scope); + expect(state.hosts.has(element)).toBeFalse(); + }); + + it("cancels pending disconnect teardown during runtime destruction", async () => { + const tagName = nextTagName("pending-destroy-card"); + const angular = new Angular(); + let injector; + let rootScope; + let compile; + + angular + .bootstrap(app, []) + .invoke((_$injector_, _$rootScope_, _$compile_) => { + injector = _$injector_; + rootScope = _$rootScope_; + compile = _$compile_; + }); + + const state = createWebComponentRuntimeState(); + const service = createWebComponentService( + injector, + rootScope, + compile, + state, + ); + + service.defineAppComponent(tagName, { template: "ready" }); + const element = document.createElement(tagName); + app.appendChild(element); + await wait(); + + element.remove(); + destroyWebComponentRuntimeState(state); + + expect(state.hosts.size).toBe(0); + }); + + it("ignores scopes that were already destroyed during runtime teardown", () => { + const state = createWebComponentRuntimeState(); + const destroyedScope = { $handler: { _destroyed: true } }; + state.scopes.add(destroyedScope); + + destroyWebComponentRuntimeState(state); + + expect(state.scopes.size).toBe(0); + }); + + it("destroys active scopes that are not connected to a host", () => { + const state = createWebComponentRuntimeState(); + const scope = { + $handler: { _destroyed: false }, + $destroy: jasmine.createSpy("$destroy"), + }; + + state.scopes.add(scope); + destroyWebComponentRuntimeState(state); + + expect(scope.$destroy).toHaveBeenCalledOnceWith(); + }); + it("destroys the owned scope after disconnect", async () => { const tagName = nextTagName("destroy-card"); diff --git a/src/services/web-component/web-component.ts b/src/services/web-component/web-component.ts index 98cfd8bb5..d98ee9a1f 100644 --- a/src/services/web-component/web-component.ts +++ b/src/services/web-component/web-component.ts @@ -1,9 +1,4 @@ -import { - _compile, - _injector, - _rootScope, - _scope, -} from "../../injection-tokens.ts"; +import { _scope } from "../../injection-tokens.ts"; import { dealoc, getInheritedData, setScope } from "../../shared/dom.ts"; import { kebobString } from "../../shared/strings.ts"; import { @@ -19,7 +14,7 @@ import { uppercase, } from "../../shared/utils.ts"; -type WebComponentInputType = +export type WebComponentInputType = | StringConstructor | NumberConstructor | BooleanConstructor @@ -190,6 +185,12 @@ export interface WebComponentService { ): ng.Scope & T; } +/** Application-wide defaults for scoped custom elements. */ +export interface WebComponentConfig { + /** Defaults merged into every `appComponent(...)` declaration. */ + defaults?: Partial; +} + interface InputDefinition { attribute: string; default?: unknown; @@ -210,6 +211,16 @@ interface ScopeElementDefinition { injector: ng.InjectorService; inputs: InputDefinition[]; options: AppComponentOptions; + state: WebComponentRuntimeState; +} + +/** @internal */ +export interface WebComponentRuntimeState { + defaults: Partial; + readonly definitions: Set; + readonly hosts: Set; + readonly scopes: Set; + destroyed: boolean; } const scopeElementDefinitions = new WeakMap< @@ -240,74 +251,140 @@ const scopeElementReflectingAttributes = new WeakMap< Set >(); -/** Provider for scoped custom element integration. */ -export class WebComponentProvider { - /** Default options merged into every app component definition. */ - defaults: Partial = {}; - - $get = [ - _injector, - _rootScope, - _compile, - ( - injector: ng.InjectorService, - rootScope: ng.Scope, - compile: ng.CompileService, - ): WebComponentService => { - const createElementScope = >( - host: HTMLElement, - initialState: T = {} as T, - options: ElementScopeOptions = {}, - ): ng.Scope & T => { - const parentScope = (options.parentScope ?? - getInheritedData(host, _scope) ?? - getInheritedData(host.parentNode ?? host, _scope) ?? - rootScope) as ng.Scope; - - const scope = options.isolate - ? parentScope.$newIsolate(initialState as ng.Scope) - : parentScope.$new(initialState as ng.Scope); - - setScope(host, scope); - - return scope as ng.Scope & T; - }; +/** @internal */ +export function createWebComponentRuntimeState(): WebComponentRuntimeState { + return { + defaults: {}, + definitions: new Set(), + hosts: new Set(), + scopes: new Set(), + destroyed: false, + }; +} - return { +/** @internal */ +export function applyWebComponentConfiguration( + state: WebComponentRuntimeState, + config: WebComponentConfig, +): void { + if (config.defaults !== undefined) { + state.defaults = { + ...state.defaults, + ...config.defaults, + }; + } +} + +/** @internal */ +export function destroyWebComponentRuntimeState( + state: WebComponentRuntimeState, +): void { + if (state.destroyed) return; + + state.destroyed = true; + + for (const host of Array.from(state.hosts)) { + const timer = scopeElementDestroyTimers.get(host); + + if (timer) { + clearTimeout(timer); + scopeElementDestroyTimers.delete(host); + } + + disconnectScopeElement(host); + } + + for (const scope of Array.from(state.scopes)) { + if (!scope.$handler._destroyed) scope.$destroy(); + } + + for (const definition of state.definitions) { + scopeElementDefinitions.delete(definition); + scopeElementInputs.delete(definition); + } + + state.definitions.clear(); + state.hosts.clear(); + state.scopes.clear(); +} + +/** @internal */ +export function createWebComponentService( + injector: ng.InjectorService, + rootScope: ng.Scope, + compile: ng.CompileService, + state: WebComponentRuntimeState, +): WebComponentService { + const assertActive = (): void => { + if (state.destroyed) { + throw new Error("Cannot use $webComponent after runtime teardown"); + } + }; + + const createElementScope = >( + host: HTMLElement, + initialState: T = {} as T, + options: ElementScopeOptions = {}, + ): ng.Scope & T => { + assertActive(); + + const parentScope = (options.parentScope ?? + getInheritedData(host, _scope) ?? + getInheritedData(host.parentNode ?? host, _scope) ?? + rootScope) as ng.Scope; + + const scope = options.isolate + ? parentScope.$newIsolate(initialState as ng.Scope) + : parentScope.$new(initialState as ng.Scope); + + state.scopes.add(scope); + scope.$on("$destroy", () => { + state.scopes.delete(scope); + }); + setScope(host, scope); + + return scope as ng.Scope & T; + }; + + return { + createElementScope, + defineAppComponent: >( + name: string, + options: AppComponentOptions, + ) => { + assertActive(); + + const mergedOptions = { + ...state.defaults, + ...options, + } as AppComponentOptions; + + return defineAppComponent( + name, + mergedOptions, + injector, + compile, createElementScope, - defineAppComponent: >( - name: string, - options: AppComponentOptions, - ) => { - const mergedOptions = { - ...this.defaults, - ...options, - } as AppComponentOptions; - - return defineAppComponent( - name, - mergedOptions, - injector, - compile, - createElementScope, - ); - }, - defineElement: >( - name: string, - elementClass: ScopeElementConstructor, - ) => { - return defineScopeElement( - name, - elementClass, - resolveScopeElementOptions(elementClass), - injector, - compile, - createElementScope, - ); - }, - }; + state, + ); }, - ]; + defineElement: >( + name: string, + elementClass: ScopeElementConstructor, + ) => { + assertActive(); + + return defineScopeElement( + name, + elementClass, + resolveScopeElementOptions(elementClass), + injector, + compile, + createElementScope, + state, + ); + }, + }; } function defineAppComponent( @@ -316,6 +393,7 @@ function defineAppComponent( injector: ng.InjectorService, compile: ng.CompileService, createElementScope: WebComponentService["createElementScope"], + state: WebComponentRuntimeState, ): CustomElementConstructor { class AngularTsAppComponent extends ScopeElement { static template = options.template; @@ -363,6 +441,7 @@ function defineAppComponent( injector, compile, createElementScope, + state, ); } @@ -373,6 +452,7 @@ function defineScopeElement( injector: ng.InjectorService, compile: ng.CompileService, createElementScope: WebComponentService["createElementScope"], + state: WebComponentRuntimeState, ): CustomElementConstructor { const existing = customElements.get(name); @@ -386,8 +466,10 @@ function defineScopeElement( injector, inputs, options: options as AppComponentOptions, + state, }); scopeElementInputs.set(elementClass as AnyScopeElementConstructor, inputs); + state.definitions.add(elementClass as AnyScopeElementConstructor); installScopeElementInputs(elementClass as ScopeElementConstructor, inputs); customElements.define(name, elementClass); @@ -496,6 +578,8 @@ function syncScopeElementAttribute( const definition = getScopeElementDefinition(host); + if (!definition) return; + const input = definition.inputs.find( (candidate) => candidate.attribute === attribute, ); @@ -519,6 +603,8 @@ function connectScopeElement(host: HTMLElement): void { const definition = getScopeElementDefinition(host); + if (!definition) return; + const options = definition.options; const renderRoot = resolveRenderRoot(host, options.shadow); @@ -538,6 +624,7 @@ function connectScopeElement(host: HTMLElement): void { element.root = renderRoot; scopeElementScopes.set(host, scope); scopeElementContexts.set(host, context); + definition.state.hosts.add(host); applyInputDefaults(host, definition.inputs); upgradeOwnProperties(host, definition.inputs); applyAttributes(host, definition.inputs, scope); @@ -561,6 +648,8 @@ function disconnectScopeElement(host: HTMLElement): void { cleanup?.(); scopeElementCleanupFns.delete(host); + const definition = getScopeElementDefinition(host); + const context = scopeElementContexts.get(host); const element = host as ScopeElement; element.disconnected?.(); @@ -569,25 +658,19 @@ function disconnectScopeElement(host: HTMLElement): void { scope.$destroy(); } - const definition = getScopeElementDefinition(host); - scopeElementScopes.delete(host); scopeElementContexts.delete(host); - clearRenderedContent(resolveRenderRoot(host, definition.options.shadow)); + definition?.state.hosts.delete(host); + + if (context) clearRenderedContent(context.root); } -function getScopeElementDefinition(host: HTMLElement): ScopeElementDefinition { - const definition = scopeElementDefinitions.get( +function getScopeElementDefinition( + host: HTMLElement, +): ScopeElementDefinition | undefined { + return scopeElementDefinitions.get( host.constructor as AnyScopeElementConstructor, ); - - if (!definition) { - throw new Error( - `Custom element ${host.localName} was not registered with $webComponent`, - ); - } - - return definition; } function getScopeElementContext( diff --git a/src/services/websocket/websocket.spec.ts b/src/services/websocket/websocket.spec.ts index 74fa26ba8..9682bfb6c 100644 --- a/src/services/websocket/websocket.spec.ts +++ b/src/services/websocket/websocket.spec.ts @@ -3,9 +3,13 @@ import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; import { wait } from "../../shared/test-utils.ts"; +import { + createWebSocketRuntimeConfiguration, + destroyWebSocketRuntimeConfiguration, +} from "./websocket.ts"; describe("$websocket", () => { - let websocket, el, RealWebSocket, sockets; + let angular, websocket, el, RealWebSocket, sockets; beforeEach(() => { el = document.getElementById("app"); @@ -18,6 +22,7 @@ describe("$websocket", () => { this.url = url; this.protocols = protocols; this.sent = []; + this.closeCalls = 0; sockets.push(this); setTimeout(() => this.onopen?.({ type: "open" }), 0); } @@ -27,18 +32,52 @@ describe("$websocket", () => { } close() { + this.closeCalls++; this.onclose?.({ type: "close", code: 1000 }); } }; - const angular = new Angular(); + angular = new Angular(); angular.bootstrap(el, []).invoke((_$websocket_) => { websocket = _$websocket_; }); }); + it("tears down runtime configuration idempotently", () => { + const configuration = createWebSocketRuntimeConfiguration(); + const connection = { close: jasmine.createSpy("close") }; + configuration.connections.add(connection); + + destroyWebSocketRuntimeConfiguration(configuration); + destroyWebSocketRuntimeConfiguration(configuration); + + expect(connection.close).toHaveBeenCalledTimes(1); + }); + + it("exposes send, reconnect, error, and idempotent close behavior", async () => { + const errors = []; + const connection = websocket("ws://example.test/socket", { + heartbeatTimeout: 0, + onError: (event) => errors.push(event), + }); + + await wait(10); + connection.send("hello"); + sockets[0].onerror({ type: "error" }); + connection.reconnect(); + await wait(10); + + expect(sockets[0].sent).toEqual(['"hello"']); + expect(errors).toEqual([{ type: "error" }]); + expect(sockets.length).toBe(2); + + connection.close(); + connection.close(); + }); + afterEach(() => { + angular._composition.destroy(); window.WebSocket = RealWebSocket; dealoc(el); }); @@ -48,7 +87,7 @@ describe("$websocket", () => { const allMessages = []; - const connection = websocket("ws://example.test/socket", [], { + const connection = websocket("ws://example.test/socket", { heartbeatTimeout: 0, onProtocolMessage: (message) => protocolMessages.push(message), onMessage: (message) => allMessages.push(message), @@ -72,7 +111,7 @@ describe("$websocket", () => { const allMessages = []; - const connection = websocket("ws://example.test/socket", [], { + const connection = websocket("ws://example.test/socket", { heartbeatTimeout: 0, onProtocolMessage: (message) => protocolMessages.push(message), onMessage: (message) => allMessages.push(message), @@ -89,4 +128,149 @@ describe("$websocket", () => { expect(allMessages[0]).toEqual({ count: 1 }); connection.close(); }); + + it("should reconnect after unexpected close with configured retry policy", async () => { + const reconnects = []; + + const connection = websocket("ws://example.test/socket", { + heartbeatTimeout: 0, + maxRetries: 1, + retryDelay: 5, + onReconnect: (attempt) => reconnects.push(attempt), + }); + + await wait(10); + + sockets[0].onclose({ type: "close", code: 1006 }); + + await wait(30); + + expect(sockets.length).toBeGreaterThanOrEqual(2); + expect(reconnects).toEqual([1]); + connection.close(); + }); + + it("should stop reconnect attempts after explicit close", async () => { + const reconnects = []; + + const connection = websocket("ws://example.test/socket", { + heartbeatTimeout: 0, + retryDelay: 5, + onReconnect: (attempt) => reconnects.push(attempt), + }); + + await wait(10); + + connection.close(); + await wait(30); + + expect(sockets.length).toBe(1); + expect(reconnects).toEqual([]); + }); + + it("should close owned connections when the runtime is destroyed", async () => { + websocket("ws://example.test/socket", { + heartbeatTimeout: 0, + }); + + await wait(10); + angular._composition.destroy(); + + expect(sockets[0].closeCalls).toBe(1); + expect(() => websocket("ws://example.test/after-destroy")).toThrowError( + "Cannot create a WebSocket connection after runtime teardown", + ); + }); + + it("should release explicitly closed connections from runtime ownership", async () => { + const connection = websocket("ws://example.test/socket", { + heartbeatTimeout: 0, + }); + + await wait(10); + connection.close(); + angular._composition.destroy(); + + expect(sockets[0].closeCalls).toBe(1); + }); + + it("should reconnect once after heartbeat timeout", async () => { + const reconnects = []; + + const connection = websocket("ws://example.test/socket", { + heartbeatTimeout: 20, + maxRetries: 1, + retryDelay: 5, + onReconnect: (attempt) => reconnects.push(attempt), + }); + + await wait(35); + + expect(sockets.length).toBe(2); + expect(reconnects).toEqual([1]); + connection.close(); + }); + + it("should apply app configured defaults when opening runtime connections", async () => { + const configuredEl = document.createElement("div"); + const angular = new Angular(); + let configuredWebSocket; + const messages = []; + + document.body.appendChild(configuredEl); + + angular.module("configuredWebSocketDefaults", []).config({ + $websocket: { + defaults: { + heartbeatTimeout: 0, + maxRetries: 1, + protocols: ["configured-json"], + retryDelay: 5, + transformMessage(data) { + return { configured: data }; + }, + }, + }, + }); + + angular + .bootstrap(configuredEl, ["configuredWebSocketDefaults"]) + .invoke((_$websocket_) => { + configuredWebSocket = _$websocket_; + }); + + const connection = configuredWebSocket("ws://example.test/configured"); + + await wait(10); + + expect(sockets[0].protocols).toEqual(["configured-json"]); + + sockets[0].onmessage({ + type: "message", + data: "payload", + }); + + const localConnection = configuredWebSocket("ws://example.test/local", { + heartbeatTimeout: 0, + protocols: ["local-json"], + onMessage: (message) => messages.push(message), + transformMessage(data) { + return { local: data }; + }, + }); + + await wait(10); + + sockets[1].onmessage({ + type: "message", + data: "override", + }); + + expect(messages).toEqual([{ local: "override" }]); + expect(sockets[1].protocols).toEqual(["local-json"]); + + connection.close(); + localConnection.close(); + dealoc(configuredEl); + }); }); diff --git a/src/services/websocket/websocket.ts b/src/services/websocket/websocket.ts index e8c8f8521..a4c38c528 100644 --- a/src/services/websocket/websocket.ts +++ b/src/services/websocket/websocket.ts @@ -1,4 +1,3 @@ -import { _log } from "../../injection-tokens.ts"; import { ConnectionManager, type ConnectionConfig, @@ -27,7 +26,7 @@ export interface WebSocketConfig extends ConnectionConfig { */ export interface WebSocketConnection { /** Manually restart the WebSocket connection. */ - connect(): void; + reconnect(): void; /** Send a JSON-serialized message through the native WebSocket. */ send(data: unknown): void; @@ -38,24 +37,22 @@ export interface WebSocketConnection { export type WebSocketService = ( url: string, - protocols?: string[], config?: WebSocketConfig, ) => WebSocketConnection; /** - * WebSocketProvider - * Provides a pre-configured WebSocket connection as an injectable. + * @internal */ -export class WebSocketProvider { - defaults: ng.WebSocketConfig; - /** @internal */ - _$log!: LogService; - - /** - * Creates the WebSocket provider with default reconnect and message parsing behavior. - */ - constructor() { - this.defaults = { +export interface WebSocketRuntimeConfiguration { + defaults: WebSocketConfig; + readonly connections: Set; + destroyed: boolean; +} + +/** @internal */ +export function createWebSocketRuntimeConfiguration(): WebSocketRuntimeConfiguration { + return { + defaults: { protocols: [], retryDelay: 1000, maxRetries: Infinity, @@ -67,52 +64,94 @@ export class WebSocketProvider { return data; } }, + }, + connections: new Set(), + destroyed: false, + }; +} + +/** @internal */ +export function applyWebSocketConfiguration( + configuration: WebSocketRuntimeConfiguration, + config: { defaults?: WebSocketConfig }, +): void { + if (config.defaults !== undefined) { + configuration.defaults = { + ...configuration.defaults, + ...config.defaults, }; } +} - /** - * Returns the `$websocket` connection factory bound to the configured defaults. - */ - $get = [ - _log, - (log: ng.LogService): WebSocketService => { - this._$log = log; - - return ( - url: string, - protocols: string[] = [], - config: WebSocketConfig = {}, - ) => { - const mergedConfig = { ...this.defaults, ...config }; - - mergedConfig.protocols = protocols.length - ? protocols - : mergedConfig.protocols; - - return new ConnectionManager( - () => new WebSocket(url, mergedConfig.protocols), - { - ...mergedConfig, - onMessage: (data: unknown, event: Event | MessageEvent) => { - if (isRealtimeProtocolMessage(data)) { - mergedConfig.onProtocolMessage?.(data, event); - } - - mergedConfig.onMessage?.(data, event); - }, - onOpen: (event: Event) => { - mergedConfig.onOpen?.(event); - }, - onClose: (event: CloseEvent) => { - mergedConfig.onClose?.(event); - }, - onError: (event: Event) => { - mergedConfig.onError?.(event); - }, - }, - this._$log, - ); - }; - }, - ]; +/** @internal */ +export function destroyWebSocketRuntimeConfiguration( + configuration: WebSocketRuntimeConfiguration, +): void { + if (configuration.destroyed) return; + + configuration.destroyed = true; + + for (const connection of configuration.connections) connection.close(); + + configuration.connections.clear(); +} + +/** @internal */ +export function createWebSocketService( + log: LogService, + configuration: WebSocketRuntimeConfiguration, + WebSocketConstructor: typeof WebSocket, +): WebSocketService { + return (url: string, config: WebSocketConfig = {}) => { + if (configuration.destroyed) { + throw new Error( + "Cannot create a WebSocket connection after runtime teardown", + ); + } + + const mergedConfig = { ...configuration.defaults, ...config }; + const manager = new ConnectionManager( + () => new WebSocketConstructor(url, mergedConfig.protocols), + { + ...mergedConfig, + onMessage: (data: unknown, event: Event | MessageEvent) => { + if (isRealtimeProtocolMessage(data)) { + mergedConfig.onProtocolMessage?.(data, event); + } + + mergedConfig.onMessage?.(data, event); + }, + onOpen: (event: Event) => { + mergedConfig.onOpen?.(event); + }, + onClose: (event: CloseEvent) => { + mergedConfig.onClose?.(event); + }, + onError: (event: Event) => { + mergedConfig.onError?.(event); + }, + }, + log, + ); + let closed = false; + const connection: WebSocketConnection = { + reconnect() { + manager.reconnect(); + }, + send(data) { + manager.send(data); + }, + close() { + if (closed) return; + + closed = true; + configuration.connections.delete(connection); + manager.close(); + }, + }; + + configuration.connections.add(connection); + + return connection; + }; } diff --git a/src/services/webtransport/webtransport-demo.html b/src/services/webtransport/webtransport-demo.html index d7ea3ac15..56b8690e9 100644 --- a/src/services/webtransport/webtransport-demo.html +++ b/src/services/webtransport/webtransport-demo.html @@ -118,10 +118,10 @@

Datagrams

data-transform="json" data-reconnect="true" data-retry-delay="500" - data-on-open="notice = 'Datagram session open'" - data-on-message="datagramEvents = [$message].concat(datagramEvents)" - data-on-reconnect="reconnects = $attempt" - data-on-error="notice = $error.message || 'Datagram error'" + on-open="notice = 'Datagram session open'" + on-message="datagramEvents = [$message].concat(datagramEvents)" + on-reconnect="reconnects = $attempt" + on-error="notice = $error.message || 'Datagram error'" >
@@ -165,8 +165,8 @@

Streams

data-transform="json" data-reconnect="true" data-retry-delay="1000" - data-on-message="streamEvents = [$message].concat(streamEvents)" - data-on-error="notice = $error.message || 'Stream error'" + on-message="streamEvents = [$message].concat(streamEvents)" + on-error="notice = $error.message || 'Stream error'" >
diff --git a/src/services/webtransport/webtransport.spec.ts b/src/services/webtransport/webtransport.spec.ts index e41339ee1..118b6bf93 100644 --- a/src/services/webtransport/webtransport.spec.ts +++ b/src/services/webtransport/webtransport.spec.ts @@ -3,13 +3,17 @@ import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; import { wait } from "../../shared/test-utils.ts"; +import { + createWebTransportRuntimeConfiguration, + destroyWebTransportRuntimeConfiguration, +} from "./webtransport.ts"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); describe("$webTransport", () => { - let webTransport, webTransportProvider, el; + let angular, webTransport, el; const connections = []; @@ -17,30 +21,132 @@ describe("$webTransport", () => { el = document.getElementById("app"); el.innerHTML = ""; - const angular = new Angular(); - - angular.module("default", []).config(($webTransportProvider) => { - webTransportProvider = $webTransportProvider; - }); + angular = new Angular(); - angular.bootstrap(el, ["default"]).invoke((_$webTransport_) => { + angular.bootstrap(el, []).invoke((_$webTransport_) => { webTransport = _$webTransport_; }); }); afterEach(() => { connections.splice(0).forEach((connection) => connection.close()); + angular._composition.destroy(); dealoc(el); }); - it("should be available as provider", () => { - expect(webTransportProvider).toBeDefined(); - }); - it("should be available as a service", () => { expect(webTransport).toBeDefined(); }); + it("allows runtime configuration teardown to be repeated", () => { + const configuration = createWebTransportRuntimeConfiguration(); + const connection = { close: jasmine.createSpy("close") }; + configuration.connections.add(connection); + + destroyWebTransportRuntimeConfiguration(configuration); + destroyWebTransportRuntimeConfiguration(configuration); + + expect(connection.close).toHaveBeenCalledTimes(1); + }); + + it("closes owned connections when the runtime is destroyed", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "WebTransport", + ); + const instances = []; + + class MockWebTransport { + constructor() { + this.closeCalls = 0; + this.ready = Promise.resolve(); + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + this.datagrams = { + readable: new ReadableStream(), + writable: new WritableStream(), + }; + instances.push(this); + } + + close() { + this.closeCalls++; + this.resolveClosed(); + } + } + + try { + Object.defineProperty(globalThis, "WebTransport", { + configurable: true, + writable: true, + value: MockWebTransport, + }); + + const connection = webTransport("https://localhost:4433/runtime-owned"); + + await connection.ready; + angular._composition.destroy(); + await connection.closed; + + expect(instances[0].closeCalls).toBe(1); + expect(() => + webTransport("https://localhost:4433/after-destroy"), + ).toThrowError( + "Cannot create a WebTransport connection after runtime teardown", + ); + } finally { + restoreWebTransport(descriptor); + } + }); + + it("does not close explicitly closed connections twice", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "WebTransport", + ); + const instances = []; + + class MockWebTransport { + constructor() { + this.closeCalls = 0; + this.ready = Promise.resolve(); + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + this.datagrams = { + readable: new ReadableStream(), + writable: new WritableStream(), + }; + instances.push(this); + } + + close() { + this.closeCalls++; + this.resolveClosed(); + } + } + + try { + Object.defineProperty(globalThis, "WebTransport", { + configurable: true, + writable: true, + value: MockWebTransport, + }); + + const connection = webTransport("https://localhost:4433/user-closed"); + + await connection.ready; + connection.close(); + await connection.closed; + angular._composition.destroy(); + + expect(instances[0].closeCalls).toBe(1); + } finally { + restoreWebTransport(descriptor); + } + }); + it("connects to the Go WebTransport backend and echoes datagrams", async () => { const messages = []; @@ -294,6 +400,255 @@ describe("$webTransport", () => { } }); + it("reconnects with a deterministic fake native transport", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "WebTransport", + ); + const attempts = []; + const instances = []; + + class MockWebTransport { + constructor(url, options) { + this.url = url; + this.options = options; + this.ready = Promise.resolve(); + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + this.datagrams = { + readable: new ReadableStream(), + writable: new WritableStream(), + }; + instances.push(this); + } + + close() { + this.resolveClosed(); + } + + createBidirectionalStream() { + return Promise.resolve({ + readable: new ReadableStream(), + writable: new WritableStream(), + }); + } + + createUnidirectionalStream() { + return Promise.resolve(new WritableStream()); + } + } + + try { + Object.defineProperty(globalThis, "WebTransport", { + configurable: true, + writable: true, + value: MockWebTransport, + }); + + const connection = track( + webTransport("https://localhost:4433/realtime", { + maxRetries: 1, + reconnect: true, + retryDelay: 5, + onReconnect: ({ attempt }) => { + attempts.push(attempt); + }, + }), + ); + + await connection.ready; + instances[0].resolveClosed(); + await wait(30); + + expect(instances.length).toBe(2); + expect(attempts).toEqual([1]); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "WebTransport", descriptor); + } else { + delete globalThis.WebTransport; + } + } + }); + + it("does not reconnect fake native transport without reconnect policy", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "WebTransport", + ); + const instances = []; + + class MockWebTransport { + constructor() { + this.ready = Promise.resolve(); + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + this.datagrams = { + readable: new ReadableStream(), + writable: new WritableStream(), + }; + instances.push(this); + } + + close() { + this.resolveClosed(); + } + + createBidirectionalStream() { + return Promise.resolve({ + readable: new ReadableStream(), + writable: new WritableStream(), + }); + } + + createUnidirectionalStream() { + return Promise.resolve(new WritableStream()); + } + } + + try { + Object.defineProperty(globalThis, "WebTransport", { + configurable: true, + writable: true, + value: MockWebTransport, + }); + + const connection = track(webTransport("https://localhost:4433/realtime")); + + await connection.ready; + instances[0].resolveClosed(); + await connection.closed; + await wait(30); + + expect(instances.length).toBe(1); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "WebTransport", descriptor); + } else { + delete globalThis.WebTransport; + } + } + }); + + it("should apply app configured defaults when opening runtime connections", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "WebTransport", + ); + const configuredEl = document.createElement("div"); + const configuredAngular = new Angular(); + const attempts = []; + const instances = []; + let configuredWebTransport; + + class MockWebTransport { + constructor(url, options) { + this.url = url; + this.options = options; + this.ready = Promise.resolve(); + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + this.datagrams = { + readable: new ReadableStream(), + writable: new WritableStream(), + }; + instances.push(this); + } + + close() { + this.resolveClosed(); + } + + createBidirectionalStream() { + return Promise.resolve({ + readable: new ReadableStream(), + writable: new WritableStream(), + }); + } + + createUnidirectionalStream() { + return Promise.resolve(new WritableStream()); + } + } + + document.body.appendChild(configuredEl); + + try { + Object.defineProperty(globalThis, "WebTransport", { + configurable: true, + writable: true, + value: MockWebTransport, + }); + + configuredAngular.module("configuredWebTransportDefaults", []).config({ + $webTransport: { + defaults: { + allowPooling: true, + congestionControl: "low-latency", + maxRetries: 1, + reconnect: true, + retryDelay: 5, + }, + }, + }); + + configuredAngular + .bootstrap(configuredEl, ["configuredWebTransportDefaults"]) + .invoke((_$webTransport_) => { + configuredWebTransport = _$webTransport_; + }); + + const connection = configuredWebTransport( + "https://localhost:4433/configured", + { + onReconnect: ({ attempt }) => attempts.push(attempt), + }, + ); + + await connection.ready; + + expect(instances[0].options).toEqual({ + allowPooling: true, + congestionControl: "low-latency", + }); + + instances[0].resolveClosed(); + await wait(30); + + expect(instances.length).toBe(2); + expect(attempts).toEqual([1]); + + const localConnection = configuredWebTransport( + "https://localhost:4433/local", + { + allowPooling: false, + reconnect: false, + }, + ); + + await localConnection.ready; + + expect(instances[2].options).toEqual({ + allowPooling: false, + congestionControl: "low-latency", + }); + + localConnection.close(); + connection.close(); + } finally { + configuredAngular._composition.destroy(); + if (descriptor) { + Object.defineProperty(globalThis, "WebTransport", descriptor); + } else { + delete globalThis.WebTransport; + } + dealoc(configuredEl); + } + }); + function track(connection) { connections.push(connection); @@ -338,8 +693,8 @@ describe("ngWebTransport", () => { data-config="transportConfig" data-as="session" data-transform="text" - data-on-open="opened = true" - data-on-message="messages.push($message)" + on-open="opened = true" + on-message="messages.push($message)" >
`); @@ -364,7 +719,7 @@ describe("ngWebTransport", () => { data-config="transportConfig" data-as="session" data-transform="json" - data-on-message="events.push($message.kind)" + on-message="events.push($message.kind)" > `); @@ -394,7 +749,7 @@ describe("ngWebTransport", () => { data-config="transportConfig" data-as="session" data-transform="json" - data-on-message="events.push($message.kind + ':' + $message.value)" + on-message="events.push($message.kind + ':' + $message.value)" > `); @@ -418,7 +773,7 @@ describe("ngWebTransport", () => { data-config="transportConfig" data-as="session" data-transform="json" - data-on-error="errors.push($error.name + ':' + $text)" + on-error="errors.push($error.name + ':' + $text)" > `); @@ -447,7 +802,7 @@ describe("ngWebTransport", () => { data-reconnect="true" data-retry-delay="10" data-max-retries="1" - data-on-reconnect="reconnects.push($attempt)" + on-reconnect="reconnects.push($attempt)" > `); @@ -530,6 +885,38 @@ describe("ngWebTransport", () => { expect(swapped).toBe(true); }); + it("uses a valid host swap mode when the message omits one", async () => { + const { url, config } = await webTransportTestConfig(); + + $scope.transportUrl = url; + $scope.transportConfig = config; + + compileDirective('
'); + compileDirective(` +
+ `); + + await eventually(() => $scope.session); + track($scope.session); + await $scope.session.sendText( + JSON.stringify({ + html: "Host swap", + target: "#host-swap-feed", + }), + ); + await eventually( + () => el.querySelector("#host-swap-feed").textContent === "Host swap", + ); + + expect(el.querySelector("#host-swap-feed").textContent).toBe("Host swap"); + }); + it("uses $animate for realtime protocol swaps when animate is enabled", async () => { const { url, config } = await webTransportTestConfig(); @@ -580,6 +967,14 @@ describe("ngWebTransport", () => { } }); +function restoreWebTransport(descriptor) { + if (descriptor) { + Object.defineProperty(globalThis, "WebTransport", descriptor); + } else { + delete globalThis.WebTransport; + } +} + async function webTransportTestConfig(config = {}) { const response = await fetch("http://localhost:3000/webtransport/cert-hash"); diff --git a/src/services/webtransport/webtransport.ts b/src/services/webtransport/webtransport.ts index 098bc7b06..0c6506895 100644 --- a/src/services/webtransport/webtransport.ts +++ b/src/services/webtransport/webtransport.ts @@ -1,4 +1,3 @@ -import { _log } from "../../injection-tokens.ts"; import { isRealtimeProtocolMessage, type RealtimeProtocolMessage, @@ -17,42 +16,10 @@ export type WebTransportBufferInput = | ArrayBufferView | Uint8Array; -export interface WebTransportCertificateHash { - algorithm: "sha-256" | "SHA-256"; - value: BufferSource; -} - -export interface WebTransportOptions { - allowPooling?: boolean; - congestionControl?: "default" | "throughput" | "low-latency"; - requireUnreliable?: boolean; - serverCertificateHashes?: WebTransportCertificateHash[]; -} - -export interface NativeWebTransport { - readonly ready: Promise; - readonly closed: Promise; - readonly datagrams: { - readonly readable: ReadableStream; - readonly writable: WritableStream; - }; - readonly incomingUnidirectionalStreams?: ReadableStream< - ReadableStream - >; - close(closeInfo?: { closeCode?: number; reason?: string }): void; - createBidirectionalStream(): Promise<{ - readable: ReadableStream; - writable: WritableStream; - }>; - createUnidirectionalStream(): Promise< - WritableStream | { writable: WritableStream } - >; -} - -type NativeWebTransportConstructor = new ( +type WebTransportConstructor = new ( url: string, options?: WebTransportOptions, -) => NativeWebTransport; +) => WebTransport; /** Event emitted for each incoming WebTransport datagram. */ export interface WebTransportDatagramEvent { @@ -119,7 +86,7 @@ export interface WebTransportConnection { /** Resolves or rejects when the managed connection closes permanently. */ closed: Promise; /** Current browser-native WebTransport instance. Replaced after reconnects. */ - transport: NativeWebTransport; + transport: WebTransport; /** Send one unreliable datagram. */ sendDatagram(data: WebTransportBufferInput): Promise; /** Send UTF-8 text as one unreliable datagram. */ @@ -127,12 +94,9 @@ export interface WebTransportConnection { /** Send data on a client-opened reliable unidirectional stream. */ sendStream(data: WebTransportBufferInput): Promise; /** Open a reliable bidirectional stream. */ - createBidirectionalStream(): Promise<{ - readable: ReadableStream; - writable: WritableStream; - }>; + createBidirectionalStream(): Promise; /** Close the WebTransport session. */ - close(closeInfo?: { closeCode?: number; reason?: string }): void; + close(closeInfo?: WebTransportCloseInfo): void; } /** Factory function exposed as `$webTransport`. */ @@ -141,15 +105,57 @@ export type WebTransportService = ( config?: WebTransportConfig, ) => WebTransportConnection; +/** @internal */ +export interface WebTransportRuntimeConfiguration { + defaults: WebTransportConfig; + readonly connections: Set; + destroyed: boolean; +} + +/** @internal */ +export function createWebTransportRuntimeConfiguration(): WebTransportRuntimeConfiguration { + return { + defaults: {}, + connections: new Set(), + destroyed: false, + }; +} + +/** @internal */ +export function applyWebTransportConfiguration( + configuration: WebTransportRuntimeConfiguration, + config: { defaults?: WebTransportConfig }, +): void { + if (config.defaults !== undefined) { + configuration.defaults = { + ...configuration.defaults, + ...config.defaults, + }; + } +} + +/** @internal */ +export function destroyWebTransportRuntimeConfiguration( + configuration: WebTransportRuntimeConfiguration, +): void { + if (configuration.destroyed) return; + + configuration.destroyed = true; + + for (const connection of configuration.connections) connection.close(); + + configuration.connections.clear(); +} + class ManagedWebTransportConnection implements WebTransportConnection { static $nonscope = true; ready!: Promise; closed: Promise; - transport!: NativeWebTransport; + transport!: WebTransport; private readonly _url: string; - private readonly _TransportCtor: NativeWebTransportConstructor; + private readonly _TransportCtor: WebTransportConstructor; private readonly _transportOptions: WebTransportOptions; private readonly _config: WebTransportConfig; private readonly _log: LogService; @@ -163,7 +169,7 @@ class ManagedWebTransportConnection implements WebTransportConnection { constructor( url: string, - TransportCtor: NativeWebTransportConstructor, + TransportCtor: WebTransportConstructor, transportOptions: WebTransportOptions, config: WebTransportConfig, log: LogService, @@ -199,9 +205,7 @@ class ManagedWebTransportConnection implements WebTransportConnection { await this.transport.ready; const stream = await this.transport.createUnidirectionalStream(); - const writable = "writable" in stream ? stream.writable : stream; - - const writer = writable.getWriter(); + const writer = stream.getWriter(); try { await writer.write(this._toBytes(data)); @@ -211,16 +215,15 @@ class ManagedWebTransportConnection implements WebTransportConnection { } } - async createBidirectionalStream(): Promise<{ - readable: ReadableStream; - writable: WritableStream; - }> { + async createBidirectionalStream(): Promise { await this.transport.ready; return this.transport.createBidirectionalStream(); } - close(closeInfo?: { closeCode?: number; reason?: string }): void { + close(closeInfo?: WebTransportCloseInfo): void { + if (this._closing || this._closedSettled) return; + this._closing = true; this._clearReconnectTimer(); @@ -232,7 +235,7 @@ class ManagedWebTransportConnection implements WebTransportConnection { } private _open(attempt = 0, previousError?: unknown): void { - let transport: NativeWebTransport; + let transport: WebTransport; try { transport = new this._TransportCtor(this._url, this._transportOptions); @@ -378,10 +381,11 @@ class ManagedWebTransportConnection implements WebTransportConnection { this._reconnectTimer = undefined; } - private async _readDatagrams(transport: NativeWebTransport): Promise { + private async _readDatagrams(transport: WebTransport): Promise { if (!this._config.onDatagram && !this._config.onProtocolMessage) return; - const reader = transport.datagrams.readable.getReader(); + const reader = + transport.datagrams.readable.getReader() as ReadableStreamDefaultReader; try { for (;;) { @@ -448,75 +452,79 @@ class ManagedWebTransportConnection implements WebTransportConnection { } } -/** Provider for the `$webTransport` service. */ -export class WebTransportProvider { - /** Default options merged into every `$webTransport` call. */ - defaults: WebTransportConfig = {}; - - /** - * Returns a factory that opens browser-native WebTransport sessions. - */ - $get = [ - _log, - (log: ng.LogService): WebTransportService => { - return (url: string, config: WebTransportConfig = {}) => { - validateWebTransportUrl(url); - - const WebTransportCtor = ( - globalThis as typeof globalThis & { - WebTransport?: NativeWebTransportConstructor; - } - ).WebTransport; - - if (!isFunction(WebTransportCtor)) { - throw new Error("WebTransport API is not available in this browser"); - } +/** @internal */ +export function createWebTransportService( + log: LogService, + configuration: WebTransportRuntimeConfiguration, + getWebTransportConstructor: () => WebTransportConstructor | undefined, + baseUrl: string, +): WebTransportService { + return (url: string, config: WebTransportConfig = {}) => { + if (configuration.destroyed) { + throw new Error( + "Cannot create a WebTransport connection after runtime teardown", + ); + } + + validateWebTransportUrl(url, baseUrl); - const mergedConfig = { ...this.defaults, ...config }; - - const { - onOpen, - onClose, - onError, - onDatagram, - onProtocolMessage, - transformDatagram, - reconnect, - retryDelay, - maxRetries, - onReconnect, - ...transportOptions - } = mergedConfig; - - return new ManagedWebTransportConnection( - url, - WebTransportCtor, - transportOptions, - { - onOpen, - onClose, - onError, - onDatagram, - onProtocolMessage, - transformDatagram, - reconnect, - retryDelay, - maxRetries, - onReconnect, - }, - log, - ); - }; - }, - ]; + const WebTransportCtor = getWebTransportConstructor(); + + if (!isFunction(WebTransportCtor)) { + throw new Error("WebTransport API is not available in this browser"); + } + + const mergedConfig = { ...configuration.defaults, ...config }; + + const { + onOpen, + onClose, + onError, + onDatagram, + onProtocolMessage, + transformDatagram, + reconnect, + retryDelay, + maxRetries, + onReconnect, + ...transportOptions + } = mergedConfig; + + const connection = new ManagedWebTransportConnection( + url, + WebTransportCtor, + transportOptions, + { + onOpen, + onClose, + onError, + onDatagram, + onProtocolMessage, + transformDatagram, + reconnect, + retryDelay, + maxRetries, + onReconnect, + }, + log, + ); + const release = (): void => { + configuration.connections.delete(connection); + }; + + configuration.connections.add(connection); + void connection.closed.then(release, release); + + return connection; + }; } -function validateWebTransportUrl(url: string): void { +function validateWebTransportUrl(url: string, baseUrl: string): void { if (!isString(url) || !url) { throw new Error("WebTransport URL required"); } - const parsed = new URL(url, window.location.href); + const parsed = new URL(url, baseUrl); if (parsed.protocol !== "https:") { throw new Error("WebTransport URL must use https"); diff --git a/src/services/worker/README.md b/src/services/worker/README.md new file mode 100644 index 000000000..3fb59de2d --- /dev/null +++ b/src/services/worker/README.md @@ -0,0 +1,117 @@ +# Worker Service + +`$worker` wraps page-owned Web Workers as managed AngularTS service +connections. It is for background computation owned by the current page, not +browser-level page control. Service workers stay separate under +`src/services/service-worker`. + +## Public Surface + +- `$worker(scriptPath, config?)`: creates a managed module `Worker`. +- `WorkerConfig`: optional callbacks and lifecycle policy. +- `WorkerConnection`: managed connection with `postMessage`, `onMessage`, + `restart`, and `terminate`. +- `createWorkerConnection(scriptPath, config?)`: testable factory used by the + provider. + +## Lifecycle Contract + +- Construction creates a native `Worker` immediately with `{ type: "module" }`. +- `postMessage(data)` sends to the current native worker. +- `onMessage(listener)` subscribes without mutating connection state and returns + a disposer. +- `restart()` terminates the current worker and creates a replacement unless the + connection was already terminated. +- `terminate()` is idempotent. It clears managed listeners and native event + handlers, marks the connection permanently terminated, and calls the native + worker's `terminate()` once. +- Connections created through injected `$worker` are terminated when their + owning `AppContext` is destroyed. +- The service does not bind connections to individual DOM scopes. Callers and + directives should still call `terminate()` when a connection has a shorter + lifetime than the application. + +## Policy Contract + +Current defaults: + +- `autoRestart: false` +- JSON string message parsing with fallback to raw strings +- an empty `onError` callback +- `$log` and `$exceptionHandler` from dependency injection when called through + `$worker` + +Call-site config can provide an initial message listener and override error, +restart, and message-transform behavior. Scope or directive owners should call +`terminate()` when their connection lifetime ends before application teardown. + +## Failure Contract + +- Missing script paths throw synchronously with `Worker script path required`. +- Native worker `error` events call `onError`. +- If `autoRestart` is enabled, native worker errors restart the underlying + worker after `onError`. +- Message transform exceptions are swallowed and the original message payload is + delivered. +- Posting after `terminate()` logs `Worker already terminated` and still attempts + to call the native worker. Native `postMessage` failures are logged as + `Worker post failed`. +- `restart()` after `terminate()` logs `Worker cannot restart after terminate` + and does not create a replacement worker. + +## Reactivity Contract + +`$worker` is callback-driven and does not expose reactive status today. Worker +state should become reactive only if AppContext ownership and template use cases +justify it. Until then, directives may project results into scope while the +service remains DOM-independent. + +## Recovery Contract + +Recovery is in-memory and connection-local. `autoRestart` replaces the native +worker after an error, but AngularTS does not replay messages, restore worker +internal state, or persist worker progress. Applications should use workflow or +supervisor policy when work must be resumable or durable. + +## Scheduling And Ordering + +Native worker `message` and `error` events drive callback order. `restart()` +and `autoRestart` replace the worker synchronously after termination. There is +no hidden retry timer, polling loop, or queue. + +## Native Interop + +The service intentionally keeps the native `Worker` object private. The stable +interop boundary is `postMessage`, `restart`, `terminate`, `onMessage`, +`onError`, and +`transformMessage`. Exposing the native worker remains a future policy decision +because it would let application code bypass cleanup and restart semantics. + +## Dependency Replacement Contract + +`$worker` replaces repeated app boilerplate around module worker construction, +message parsing, error callbacks, restart-on-error behavior, and termination. +It does not replace worker script authoring, transferable-object design, +durable job queues, or service worker lifecycle. + +## Composition Contract + +- Lower-level: native `Worker`, `postMessage`, `message`, `error`, and + `terminate`. +- Same layer: `ng-worker` directive owns DOM-triggered worker execution and + result projection. +- Higher-level: workflows and supervisors may use `$worker` as an execution + activity, but durable retry/replay policy stays with workflow/supervisor. +- Separate layer: `$serviceWorker` will own browser-controlled registration, + update, controller-change, and app-to-service-worker messaging. + +## Test Harness + +- `src/services/worker/worker.spec.ts` replaces `window.Worker` with a fake + module worker. +- Tests cover script validation, message posting, default JSON parsing, + transform fallback, error callbacks, configured restart, termination, + post-after-terminate behavior, failed `postMessage`, dependency injection, + and application-owned teardown. +- `src/services/worker/worker.test.ts` runs the Jasmine suite through + Playwright. diff --git a/src/services/worker/worker.spec.ts b/src/services/worker/worker.spec.ts index 9bb5d1098..b4ae315eb 100644 --- a/src/services/worker/worker.spec.ts +++ b/src/services/worker/worker.spec.ts @@ -2,7 +2,11 @@ /// import { Angular } from "../../angular.ts"; import { dealoc } from "../../shared/dom.ts"; -import { createWorkerConnection } from "./worker.ts"; +import { + createWorkerConnection, + createWorkerRuntimeState, + destroyWorkerRuntimeState, +} from "./worker.ts"; describe("$worker", () => { let RealWorker; @@ -16,6 +20,7 @@ describe("$worker", () => { this.options = options; this.sent = []; this.terminated = false; + this.terminateCalls = 0; this.onmessage = undefined; this.onerror = undefined; workers.push(this); @@ -27,6 +32,7 @@ describe("$worker", () => { terminate() { this.terminated = true; + this.terminateCalls++; } } @@ -55,11 +61,9 @@ describe("$worker", () => { }); it("creates a module worker and posts messages", () => { - const connection = createWorkerConnection("/workers/echo.js", { - logger: log, - }); + const connection = createWorkerConnection("/workers/echo.js"); - connection.post({ value: 1 }); + connection.postMessage({ value: 1 }); expect(workers.length).toBe(1); expect(workers[0].scriptPath).toBe("/workers/echo.js"); @@ -70,7 +74,6 @@ describe("$worker", () => { it("parses JSON string messages by default", () => { const received = []; const connection = createWorkerConnection("/workers/echo.js", { - logger: log, onMessage: (data, event) => received.push({ data, event }), }); const event = { data: '{"ok":true}' }; @@ -85,7 +88,6 @@ describe("$worker", () => { const received = []; createWorkerConnection("/workers/echo.js", { - logger: log, onMessage: (data) => received.push(data), }); @@ -99,7 +101,6 @@ describe("$worker", () => { const payload = { value: 2 }; createWorkerConnection("/workers/echo.js", { - logger: log, onMessage: (data) => received.push(data), }); @@ -112,7 +113,6 @@ describe("$worker", () => { const received = []; createWorkerConnection("/workers/echo.js", { - logger: log, transformMessage: () => { throw new Error("bad transform"); }, @@ -126,8 +126,8 @@ describe("$worker", () => { it("handles worker errors and restarts when configured", () => { const errors = []; + spyOn(console, "info"); const connection = createWorkerConnection("/workers/echo.js", { - logger: log, autoRestart: true, onError: (error) => errors.push(error), }); @@ -136,69 +136,121 @@ describe("$worker", () => { workers[0].onerror(error); expect(errors).toEqual([error]); - expect(log.info).toHaveBeenCalledWith("Worker: restarting..."); + expect(console.info).toHaveBeenCalledWith("Worker: restarting..."); expect(workers[0].terminated).toBeTrue(); expect(workers.length).toBe(2); - connection.post("after restart"); + connection.postMessage("after restart"); expect(workers[1].sent).toEqual(["after restart"]); }); it("does not restart after termination", () => { + spyOn(console, "warn"); const connection = createWorkerConnection("/workers/echo.js", { - logger: log, autoRestart: true, }); connection.terminate(); connection.restart(); - expect(log.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Worker cannot restart after terminate", ); expect(workers.length).toBe(1); }); + it("terminates idempotently and releases native handlers", () => { + const connection = createWorkerConnection("/workers/echo.js"); + + const listener = () => undefined; + const unsubscribe = connection.onMessage(listener); + + unsubscribe(); + connection.terminate(); + connection.terminate(); + + expect(workers[0].terminateCalls).toBe(1); + expect(workers[0].onmessage).toBeNull(); + expect(workers[0].onerror).toBeNull(); + }); + + it("tears down worker runtime state idempotently", () => { + const state = createWorkerRuntimeState(); + const connection = { terminate: jasmine.createSpy("terminate") }; + state.connections.add(connection); + + destroyWorkerRuntimeState(state); + destroyWorkerRuntimeState(state); + + expect(connection.terminate).toHaveBeenCalledTimes(1); + }); + it("warns but still attempts to post after termination", () => { - const connection = createWorkerConnection("/workers/echo.js", { - logger: log, - }); + spyOn(console, "warn"); + const connection = createWorkerConnection("/workers/echo.js"); connection.terminate(); - connection.post("late"); + connection.postMessage("late"); - expect(log.warn).toHaveBeenCalledWith("Worker already terminated"); + expect(console.warn).toHaveBeenCalledWith("Worker already terminated"); expect(workers[0].sent).toEqual(["late"]); }); it("logs failed postMessage calls", () => { - const connection = createWorkerConnection("/workers/echo.js", { - logger: log, - }); + spyOn(console, "log"); + const connection = createWorkerConnection("/workers/echo.js"); const error = new Error("post failed"); workers[0].postMessage = () => { throw error; }; - connection.post("bad"); + connection.postMessage("bad"); - expect(log.log).toHaveBeenCalledWith("Worker post failed", error); + expect(console.log).toHaveBeenCalledWith("Worker post failed", error); }); - it("provides $worker through Angular dependency injection", () => { + it("provides $worker and terminates owned workers with the runtime", () => { const app = document.getElementById("app"); + const angular = new Angular(); let workerService; dealoc(app); - new Angular().bootstrap(app, []).invoke((_$worker_) => { + angular.bootstrap(app, []).invoke((_$worker_) => { workerService = _$worker_; }); - const connection = workerService("/workers/di.js", { logger: log }); + const connection = workerService("/workers/di.js"); expect(workers[0].scriptPath).toBe("/workers/di.js"); - expect(connection.config.logger).toBe(log); + expect(connection.postMessage).toEqual(jasmine.any(Function)); + + angular._composition.destroy(); + + expect(workers[0].terminateCalls).toBe(1); + expect(() => workerService("/workers/late.js")).toThrowError( + "Cannot create a Worker after runtime teardown", + ); + + dealoc(app); + }); + + it("releases explicitly terminated workers from runtime ownership", () => { + const app = document.getElementById("app"); + const angular = new Angular(); + let workerService; + + dealoc(app); + angular.bootstrap(app, []).invoke((_$worker_) => { + workerService = _$worker_; + }); + + const connection = workerService("/workers/di.js"); + + connection.terminate(); + angular._composition.destroy(); + + expect(workers[0].terminateCalls).toBe(1); dealoc(app); }); diff --git a/src/services/worker/worker.ts b/src/services/worker/worker.ts index 60c9f9442..2a4a02f0a 100644 --- a/src/services/worker/worker.ts +++ b/src/services/worker/worker.ts @@ -1,31 +1,28 @@ -import { _exceptionHandler, _log } from "../../injection-tokens.ts"; import { assign, isString } from "../../shared/utils.ts"; export interface WorkerConfig { onMessage?: (data: unknown, event: MessageEvent) => void; onError?: (err: ErrorEvent) => void; autoRestart?: boolean; - autoTerminate?: boolean; transformMessage?: (data: unknown) => unknown; - logger?: ng.LogService; - err?: ng.ExceptionHandlerService; } -export interface DefaultWorkerConfig { - onMessage: (data: unknown, event: MessageEvent) => void; +interface DefaultWorkerConfig { onError: (err: ErrorEvent) => void; autoRestart: boolean; - autoTerminate: boolean; transformMessage: (data: unknown) => unknown; - logger: ng.LogService; - err: ng.ExceptionHandlerService; } +type WorkerConstructor = new ( + scriptURL: string | URL, + options?: WorkerOptions, +) => Worker; + export interface WorkerConnection { - post(data: unknown): void; + postMessage(data: unknown): void; + onMessage(listener: (data: unknown, event: MessageEvent) => void): () => void; terminate(): void; restart(): void; - config: WorkerConfig; } export type WorkerService = ( @@ -33,21 +30,57 @@ export type WorkerService = ( config?: WorkerConfig, ) => WorkerConnection; +/** @internal */ +export interface WorkerRuntimeState { + readonly connections: Set; + destroyed: boolean; +} + +/** @internal */ +export function createWorkerRuntimeState(): WorkerRuntimeState { + return { + connections: new Set(), + destroyed: false, + }; +} + +/** @internal */ +export function destroyWorkerRuntimeState(state: WorkerRuntimeState): void { + if (state.destroyed) return; + + state.destroyed = true; + + for (const connection of state.connections) connection.terminate(); + + state.connections.clear(); +} + /** * Creates a managed Web Worker connection. */ export function createWorkerConnection( scriptPath: string | URL, config?: WorkerConfig, +): WorkerConnection { + return createManagedWorkerConnection( + scriptPath, + config, + console as unknown as ng.LogService, + () => Worker, + ); +} + +function createManagedWorkerConnection( + scriptPath: string | URL, + config: WorkerConfig | undefined, + logger: ng.LogService, + getWorkerConstructor: () => WorkerConstructor, + onTerminate?: () => void, ): WorkerConnection { if (!scriptPath) throw new Error("Worker script path required"); const defaults: DefaultWorkerConfig = { autoRestart: false, - autoTerminate: false, - onMessage() { - /* empty */ - }, onError() { /* empty */ }, @@ -62,13 +95,18 @@ export function createWorkerConnection( return data; } }, - logger: config?.logger ?? console, - err: (config?.err ?? (() => undefined)) as ng.ExceptionHandlerService, }; const cfg = assign({}, defaults, config) as DefaultWorkerConfig; + const messageListeners = new Set< + (data: unknown, event: MessageEvent) => void + >(); - let worker = new Worker(scriptPath, { type: "module" }); + if (config?.onMessage) { + messageListeners.add(config.onMessage); + } + + let worker = new (getWorkerConstructor())(scriptPath, { type: "module" }); let terminated = false; @@ -82,7 +120,9 @@ export function createWorkerConnection( /* no-op */ } - cfg.onMessage(data, event); + for (const listener of messageListeners) { + listener(data, event); + } }; workerParam.onerror = (err: ErrorEvent) => { @@ -97,55 +137,79 @@ export function createWorkerConnection( const reconnect = () => { if (terminated) return; - cfg.logger.info("Worker: restarting..."); + logger.info("Worker: restarting..."); worker.terminate(); - worker = new Worker(scriptPath, { type: "module" }); + worker = new (getWorkerConstructor())(scriptPath, { type: "module" }); wire(worker); }; wire(worker); return { - post(data: unknown) { + postMessage(data: unknown) { if (terminated) { - cfg.logger.warn("Worker already terminated"); + logger.warn("Worker already terminated"); } try { worker.postMessage(data); } catch (err) { - cfg.logger.log("Worker post failed", err); + logger.log("Worker post failed", err); } }, + onMessage(listener) { + messageListeners.add(listener); + + return () => { + messageListeners.delete(listener); + }; + }, + terminate() { + if (terminated) return; + terminated = true; + worker.onmessage = null; + worker.onerror = null; + messageListeners.clear(); worker.terminate(); + onTerminate?.(); }, restart() { if (terminated) { - cfg.logger.warn("Worker cannot restart after terminate"); + logger.warn("Worker cannot restart after terminate"); } reconnect(); }, - - config: cfg, }; } -export class WorkerProvider { - $get = [ - _log, - _exceptionHandler, - (log: ng.LogService, exceptionHandler: ng.ExceptionHandlerService) => { - return (scriptPath: string | URL, config: WorkerConfig = {}) => - createWorkerConnection(scriptPath, { - ...config, - logger: config.logger ?? log, - err: config.err ?? exceptionHandler, - }); - }, - ]; +/** @internal */ +export function createWorkerService( + log: ng.LogService, + state: WorkerRuntimeState, + getWorkerConstructor: () => WorkerConstructor, +): WorkerService { + return (scriptPath: string | URL, config: WorkerConfig = {}) => { + if (state.destroyed) { + throw new Error("Cannot create a Worker after runtime teardown"); + } + + const connection = createManagedWorkerConnection( + scriptPath, + config, + log, + getWorkerConstructor, + () => { + state.connections.delete(connection); + }, + ); + + state.connections.add(connection); + + return connection; + }; } diff --git a/src/services/workflow/README.md b/src/services/workflow/README.md index 30f86562e..dfe4b474f 100644 --- a/src/services/workflow/README.md +++ b/src/services/workflow/README.md @@ -1,42 +1,104 @@ # Workflow Internals -This directory owns AngularTS inspectable command workflows. The implementation -in `workflow.ts` is centered on a `$machine` instance plus command execution -state, diagnostics, bounded history, snapshots, restore, retry, and repeat. +This directory owns AngularTS inspectable command workflows. The public +implementation is centered on a `WorkflowStateEngine` plus command execution +state, diagnostics, bounded history, snapshots, restore, and retry. + +Current implementation status: + +- state transitions are machine-backed by default +- API/type-level state-engine abstraction is exposed through + `WorkflowStateEngine` and `WorkflowConfig.stateEngine` +- the completed API-form hardening pass defines workflow authoring, command + results, supervisor recovery, and public-surface ergonomics + +The target architecture is to make the workflow engine an extensible abstraction +that users can build upon, with `$machine` as the default adapter. ## Responsibilities -- Create reactive workflows from `$workflow(config)` and `$workflow(scope, -config)`. -- Delegate legal mode transitions to `$machine`. +- Create reactive workflows from `$workflow(config)`. +- Delegate legal mode transitions to a state engine (currently `$machine`). - Run named synchronous or asynchronous commands with normalized results. - Convert thrown command failures into structured diagnostics. -- Track command history for inspection, retry, repeat, and snapshots. +- Track command history for inspection, retry, and snapshots. - Enforce command concurrency, timeout, cancellation, and cleanup policies. - Restore workflows from versioned snapshots and cancel stale work. ## Public Surface -- `WorkflowProvider`: registers `$workflow` as an injectable service backed by - `$machine`. +- Custom runtimes register `$workflow` together with `$machine` through + `orchestrationModule` from the `runtime/orchestration` package entry. + Normal applications use direct context-object command maps, + `defineWorkflow(config)`, `app.workflow(name, config)`, or inject `$workflow`. + +Ordinary user surface: + - `defineWorkflow(config)`: preserves strict generic inference for workflow - definitions. -- `defineCommand(command)`: preserves strict generic inference for command - handlers. + definitions, including direct inline command-map input/output when no command + map generic is supplied. +- `defineCommand(command)`: optional helper for reusable context-object command + constants that should preserve strict generic inference outside an inline + command map. - `WorkflowService`: callable service type for creating workflows. - `Workflow`: runtime object exposed to controllers, scopes, and templates. -- `WorkflowConfig`: configuration shape for identity, mode data, transitions, - commands, and production policies. +- `WorkflowConfig`: configuration shape for identity, mode data, state-tree + events, commands, and production policies. +- `WorkflowCommand`: canonical context-object command-map handler signature, + `command(context)`. +- `WorkflowCommandContext`: command handler context containing `input`, workflow + data, cancellation, cleanup, and the command name. +- `WorkflowCommandResult`: normalized command outcome used by `run()`, + and `retry()`. +- `WorkflowCommandStatus`: stable command outcome status for control flow. +- `WorkflowDiagnostic`: JSON-oriented diagnostic evidence for command and + recovery failures. +- `WorkflowHistoryEntry`: bounded command lifecycle evidence used for retry, + snapshots, and inspection. - `WorkflowSnapshot`: durable JSON-oriented state shape. - -Public methods and values exposed to callers include `id`, `current`, `data`, -`diagnostics`, `history`, `send()`, `can()`, `matches()`, `run()`, `retry()`, -`repeat()`, `cancel()`, `snapshot()`, and `restore()`. +- `WorkflowSupervisor`: runtime object for multi-workflow delegation, + persistence, recovery, aggregate diagnostics, and supervisor snapshots. +- `WorkflowSupervisorConfig`: configuration shape for supervised workflows, + persistence adapter, persistence policy, and recovery policy. +- `WorkflowSupervisorPersistence`: adapter contract for durable supervisor + snapshots. +- `WorkflowSupervisorRecoveryPolicy`: config shape for supervisor recovery + behavior. +- `indexedDbWorkflowSupervisorPersistence(config)`: built-in IndexedDB adapter + for durable supervisor snapshots. +- `app.workflow(name, config)`: registers a named workflow as a DI singleton + through the module layer. +- `app.workflowSupervisor(name, config)`: registers a named supervisor as a DI + singleton through the module layer. + +Named workflow and supervisor registration infer data, event, command, and +workflow-map types from definitions produced by `defineWorkflow(...)`; ordinary +registration does not repeat those generic arguments. + +Advanced extension surface: + +- `WorkflowStateEngine`, `WorkflowStateEngineContext`, and + `WorkflowStateEngineFactory`: state-engine adapter contract for custom + workflow engines. +- `WorkflowStateEngineSnapshot` and `WorkflowSendResult`: + state-engine adapter evidence contracts. +- `createWorkflowWorkerHost(config)`: creates a structured-cloneable workflow + message host for worker-side workflow instances. +- `createWorkflowWorkerClient(connection)`: creates a page-side client over a + `WorkerConnection`. +- `WorkflowWorkerRequest`, `WorkflowWorkerResponse`, + `WorkflowWorkerSnapshotMessage`, and `WorkflowWorkerMessage`: public protocol + messages for package authors that host workflows in Worker scripts. + +Public methods and values exposed to callers include `id`, readonly `current`, +`data`, `diagnostics`, `history`, `send()`, `can()`, `matches()`, `run()`, +`retry()`, `cancel()`, `snapshot()`, and `restore()`. ## Core Model -Each workflow wraps one machine created from the workflow's `initial`, `data`, -and `transitions`. The workflow target exposes machine mode and data through +Each workflow owns one state engine created from the workflow's `initial`, +`data`, and state definition. The default engine adapts `$machine` internally. +The workflow target exposes readonly current mode and reactive data through getters, then adds command execution APIs and append-only evidence arrays for diagnostics and history. @@ -56,12 +118,15 @@ The main flow for `run()` is: Important invariants: -- `run()`, `retry()`, and `repeat()` always return promises. +- `run()` and `retry()` always return promises. - Unknown commands and invalid options produce failed results, not thrown exceptions. - Command mutations are not rolled back when a command fails. - Restore is a hard recovery boundary: queued work is invalidated and running work is cancelled. +- Restored supervisor snapshots do not revive transient runtime statuses: + `running`, `persisting`, and `recovering` normalize to `idle` unless + non-recoverable diagnostics require `failed`. - Late command continuations from cancelled runs must not append history or write through the workflow-provided command data/context after restore. @@ -69,8 +134,6 @@ Important invariants: `$workflow(config)` creates an unbound workflow target. Assigning it to a controller or scope property lets scope proxies bind through `SCOPE_PROXY_BIND`. -`$workflow(scope, config)` immediately returns a scope proxy when the supplied -scope has a handler. Workflow bindings mirror machine bindings and are keyed by scope id. Destroyed scope bindings are removed during scheduling and active-binding lookup. The @@ -79,11 +142,11 @@ after an observer is destroyed. ## Lifecycle Contract -- Workflow construction is synchronous and creates an internal `$machine`; it - does not start commands or touch browser APIs by itself. -- `$workflow(config)` creates an unbound runtime object; `$workflow(scope, - config)` creates a scope proxy immediately when a handler is available. -- Commands start only through `run()`, `retry()`, or `repeat()`. +- Workflow construction is synchronous and creates a state engine; the default + state engine uses `$machine` internally. Construction does not start commands + or touch browser APIs by itself. +- `$workflow(config)` creates an unbound runtime object. +- Commands start only through `run()` or `retry()`. - Scope destruction never destroys the workflow, cancels commands, or clears diagnostics/history. Destroyed bindings are removed opportunistically. - `restore(snapshot)` is the hard lifecycle recovery boundary: it cancels @@ -94,21 +157,52 @@ after an observer is destroyed. - `id`, `current`, `data`, `diagnostics`, and `history` are reactive when a workflow is observed through a scope proxy. -- `send()` schedules workflow bindings after delegating to `$machine`. +- `send()` returns `WorkflowSendResult` and schedules workflow bindings after + delegating to the state engine. `can()` accepts the same event payload. - Command completion, failure, timeout, cancellation, cleanup diagnostics, - retry, repeat, and restore schedule live observing scopes. + retry and restore schedule live observing scopes. - Destroyed observing scopes stop receiving updates after opportunistic binding cleanup. Workflow commands and recovery state continue independently. - Command progress is represented through bounded diagnostics and history, not an unbounded event stream. +## UI Fragment Contract + +Workflow UI is root-owned even when the workflow runtime is app-owned. +Progress views, diagnostics panels, recovery prompts, human approval steps, and +resumed command surfaces must render through bounded compiled fragments instead +of recompiling the whole view. + +The internal proof lives in: + +```text +src/services/workflow/workflow-ui-fragment.ts +``` + +Current contract: + +- `createWorkflowUiFragmentHost(...)` is internal-only and is not exported from + the public namespace. +- Rendering a workflow UI surface uses `$compile` so the existing root-owned + fragment record is created by the compiler. +- Re-rendering replaces only the target element's owned fragment and disposes + the previous progress/recovery fragment. +- Destroying the UI binding disposes compiled nodes, scopes, listeners, and + late DOM work owned by that fragment. +- Destroying the UI binding does not cancel workflow commands, clear + diagnostics, clear history, or destroy app-owned workflow state. +- `restore(snapshot)` remains the workflow recovery boundary. UI code can + re-render a bounded recovery/progress fragment from restored state without + owning persistence or command cancellation. + ## Policy Contract -- Default command policy favors explicit command calls and bounded evidence. +- Workflow config keeps service-specific behavior explicit instead of exposing + an over-generic `policy` field. - `concurrency` controls whether duplicate command runs are allowed, rejected, or queued. - Timeout and cancellation policies use `AbortController` and `AbortSignal`. -- `retry()` and `repeat()` use recorded command evidence and live replay inputs +- `retry()` uses recorded command evidence and live replay inputs where available. - Diagnostics and history limits bound retained evidence. - Snapshot migration policy is configured per workflow and evaluated during @@ -116,15 +210,53 @@ after an observer is destroyed. - Durable persistence, multi-workflow recovery, and cross-service orchestration remain supervisor- or application-owned. +Framework policy decisions consistently use `type` as the decision field. +Machines use `policy` for their single transition gate. Router declarations use +`policy` as a grouped object with `navigation`, `transition`, and `retention` +branches. Workflows keep `concurrency`, snapshot migration, and supervisor +`persistencePolicy`/`recovery` named for their distinct semantics; they do not +pretend those controls are interchangeable policy decisions. + +## Engine Abstraction Slice + +Goal: + +- decouple `Workflow` from `$machine` at API/type level while preserving current + runtime behavior for default flows. + +Planned implementation: + +- add `WorkflowStateEngine` contract for: + - current state access (`current`) + - legality checks (`can`) + - transition execution (`send`) + - snapshot and restore +- add optional `stateEngine` config hook on `WorkflowConfig` that returns an + engine instance +- ship a built-in `$machine` adapter so existing configs continue to work unchanged +- route command-driven transition calls through the workflow-level engine adapter + +Acceptance checklist: + +- [x] Introduce `WorkflowStateEngine` type and default machine adapter. +- [x] Add optional `WorkflowConfig.stateEngine` path for user-provided engines. +- [x] Preserve `send()`, `can()`, `matches()`, `snapshot()`, and `restore()` + behavior for default machine configs. +- [x] Preserve command semantics, retries, diagnostics, and history ordering with + custom engines. +- [x] Add type-level tests validating a custom engine can be injected. +- [x] Add implementation tests proving machine-backed default behavior is unchanged. + ## Dependency Replacement Contract - `$workflow` replaces ad hoc async command orchestration, local retry/cancel helpers, command history arrays, diagnostic plumbing, and scattered promise-state flags. -- It builds on `$machine`, `AbortController`, `AbortSignal`, promises, and - structured clone behavior. +- It builds on the workflow state-engine contract, the built-in `$machine` + adapter, `AbortController`, `AbortSignal`, promises, and structured clone + behavior. - AngularTS adds reactive command evidence, stable diagnostics, bounded history, - timeout/cancellation cleanup, retry/repeat, and versioned snapshot/restore. + timeout/cancellation cleanup, retry, and versioned snapshot/restore. - `$workflow` intentionally does not replace durable persistence, background execution, service-worker queues, distributed workflow engines, or exactly-once side-effect semantics. @@ -133,7 +265,9 @@ after an observer is destroyed. ## Composition Contract -- `$workflow` is an orchestration primitive built on `$machine`. +- `$workflow` is an orchestration primitive built on a state-engine contract. +- The default state engine is backed by `$machine`, but custom engines can be + supplied through `WorkflowConfig.stateEngine`. - Workflow supervisors may build on `$workflow` for persistence policy, recovery policy, and multi-workflow coordination. - Browser services such as `$rest`, `$worker`, `$websocket`, and future @@ -172,7 +306,7 @@ after an observer is destroyed. ## Scheduling And Ordering -- `send()` delegates to the machine synchronously and schedules workflow +- `send()` delegates to the state engine synchronously and schedules workflow bindings afterward. - Commands may be synchronous or asynchronous. - `concurrency: "allow"` starts commands immediately. @@ -200,14 +334,16 @@ Command result ordering is intentionally evidence-first: concurrency. - `commandQueues`: per-command promise tails used by queue concurrency. - `replayInputs`: map of history entry ids to original live inputs for retry - and repeat before snapshot restore. + before snapshot restore. - `queueGeneration`: monotonic restore boundary used to invalidate queued work. - `bindings`: maps observing scope ids to workflow bindings. ## Integration Points -- `$machine`: owns mode transition behavior, reactive data, and machine - snapshots. +- `WorkflowStateEngine`: owns mode transition behavior, reactive data, and + state snapshots for workflows. +- `$machine`: built-in state-engine adapter used when no custom engine is + configured. - Scope proxy binding: `SCOPE_PROXY_BIND` registers observing scope handlers. - Scope scheduling: workflow bindings schedule `current`, `data`, `diagnostics`, and `history` changes. @@ -219,10 +355,154 @@ Command result ordering is intentionally evidence-first: JSON-safe projections. - `module.workflow(name, config)`: registers named workflows as DI singletons through the module layer. +- `module.workflowSupervisor(name, config)`: registers named supervisors as DI + singletons through the module layer. + +## Supervisor Contract + +Workflow supervisors compose workflows. They do not replace `$workflow`, change +command semantics, or own browser background execution. + +Supervisor responsibilities: + +- delegate `run()`, `send()`, `cancel()`, and `cancelAll()` to named workflows +- snapshot and restore multiple workflows as one versioned supervisor snapshot +- persist and load snapshots through an explicit adapter +- apply persistence policy, currently `"manual"` or `"after-command"` +- apply startup recovery through explicit `ready` when `restoreOnStart: true` + and retry recoverable command failures only when `recover()` is called +- record aggregate supervisor diagnostics for missing workflows, persistence + failures, snapshot mismatch, unknown snapshot workflows, and failed recovery + retries + +Layer boundaries: + +- Workflows own command execution, concurrency, timeout, cancellation, retry, + command status, diagnostics, history, snapshot, and restore for one + workflow. +- Supervisors own persistence policy, recovery policy, aggregate diagnostics, + startup restore, and multi-workflow coordination. +- IndexedDB is a built-in durable browser storage adapter for supervisor + snapshots only. It is not an in-memory coordination primitive. +- Web workers are future execution boundaries for hosting work off the UI + thread. +- Service workers own offline/cache/request-queue behavior. They may be called + from workflow commands as activity boundaries, but they do not own the + in-page supervisor or live workflow instances. + +## Advanced State Engine Contract + +`WorkflowStateEngine`, `WorkflowStateEngineContext`, and +`WorkflowStateEngineFactory` are extension APIs for package authors that need to +host workflow mode transitions behind a custom engine. Ordinary applications +should author `states` and let the built-in `$machine` adapter handle the +state-tree execution. + +Custom engines must preserve the public workflow invariants: readonly observed +mode, reactive workflow data, explicit `send()`, snapshot/restore support, and +no hidden command execution. + +## Worker Adapter Contract + +The worker adapter is optional transport glue for hosting workflow instances +behind a `WorkerConnection`. It is not required by `$workflow`, and the +supervisor remains the page-side owner of persistence and recovery decisions. + +Host side: + +```ts +const host = createWorkflowWorkerHost({ + workflows: { + build: buildWorkflow, + }, +}); + +self.onmessage = (event) => { + void host.handle(event.data, (message) => { + self.postMessage(message); + }); +}; +``` + +Page side: + +```ts +const client = createWorkflowWorkerClient(connection); + +const result = await client.run<{ file: string }>( + "build", + "compile", + "index.html", +); + +const snapshot = await client.snapshot(); +``` + +Protocol rules: + +- requests use stable ids and one of `"run"`, `"send"`, `"snapshot"`, or + `"restore"` +- responses return either a result or a workflow diagnostic error +- snapshot messages can be published after mutating requests +- messages must stay structured-cloneable +- page-side recovery restores snapshots explicitly after worker restart + +Example module registration: + +```ts +app.workflowSupervisor("docsSupervisor", { + id: "docs-supervisor", + workflows: { + build: buildWorkflowConfig, + publish: publishWorkflowConfig, + }, + persistence: indexedDbWorkflowSupervisorPersistence({ + database: "docs-workflows", + store: "supervisorSnapshots", + }), + persistencePolicy: "after-command", + recovery: { + restoreOnStart: true, + }, +}); +``` + +When `restoreOnStart` is enabled, supervisor construction remains synchronous. +The supervisor starts its persisted load immediately and exposes the async +boundary as `supervisor.ready`. Await `ready` before reading restored workflow +state. Startup load failures do not throw from construction; they are recorded +as supervisor diagnostics and leave `status` as `"failed"`. + +Prefer workflow handles when writing application code: + +```ts +const build = supervisor.workflow("build"); + +await build.run("compile", "index.html"); +build.send("complete", { output: "index.html" }); +``` + +The matching runtime coverage lives in: + +- named supervisor registration: + `workflow.spec.ts` `"registers named workflow supervisors as singleton injectables"` +- IndexedDB persistence: + `workflow.spec.ts` `"saves, loads, and removes workflow supervisor snapshots through IndexedDB"` +- multi-workflow recovery: + `workflow.spec.ts` `"recovers multiple workflow supervisor workflows from recoverable failures"` +- worker adapter command delegation: + `workflow.spec.ts` `"runs workflow commands through the workflow worker adapter"` +- worker adapter event delegation: + `workflow.spec.ts` `"sends workflow events through the workflow worker adapter"` +- worker adapter restart recovery: + `workflow.spec.ts` `"restores worker-hosted workflows from supervisor snapshots after restart"` +- worker adapter type contract: + `workflow-types.spec.ts` `"typechecks the workflow worker adapter contract"` ## Native Interop -- `$workflow` is a pure AngularTS runtime abstraction built on `$machine`. +- `$workflow` is a pure AngularTS runtime abstraction built on the workflow + state-engine contract. The default adapter uses `$machine`. - Native browser primitives used directly are `AbortController`, `AbortSignal`, promises, and `structuredClone()`. - Browser side effects belong in commands or workflow activities. The command @@ -261,16 +541,15 @@ and prevents cancelled continuations from writing stale results. diagnostics, history, snapshots, and recovery. `WorkflowConfig` -: Public definition for workflow id, initial mode, data, transitions, commands, -limits, timeout, concurrency, and snapshot migration. +: Public definition for workflow id, initial mode, data, state-tree events, +commands, limits, timeout, concurrency, and snapshot migration. `WorkflowCommand` -: Command handler signature. It receives command context and may return a plain -output, a normalized result, or a promise for either. +: Context-object command handler signature. It may return a plain output, a +normalized result, or a promise for either. `WorkflowCommandContext` -: Runtime context passed to commands, including workflow, data, input, command -name, cleanup registration, and abort signal. +: Runtime context passed to every command, including its typed `input` field. `WorkflowDiagnostic` : JSON-oriented diagnostic shape used for validation failures, command @@ -286,19 +565,21 @@ write guards. ## Test Harness - `workflow.spec.ts` is the deterministic runtime harness and covers commands, - diagnostics, history, retry, repeat, restore, cancellation, concurrency, + diagnostics, history, retry, restore, cancellation, concurrency, timeouts, cleanup, and scope binding. - `workflow-types.spec.ts` is the public TypeScript contract harness for strict events, commands, inputs, and outputs. - `workflow.test.ts` is the browser-facing harness for examples and template integration. +- Workflow UI fragment tests live in `workflow.spec.ts` until a public workflow + directive or view API exists. - Add supervisor and persistence fixtures outside `$workflow`; workflow tests should keep the command/runtime contract independent of durable storage. ## Testing Notes - `workflow.spec.ts` covers runtime behavior, commands, diagnostics, retry, - repeat, restore, cancellation, concurrency, timeouts, limits, cleanup, and + restore, cancellation, concurrency, timeouts, limits, cleanup, and scope binding. - `workflow-types.spec.ts` covers strict TypeScript event, command, input, and output typing. diff --git a/src/services/workflow/SUPERVISOR_IMPLEMENTATION_ROADMAP.md b/src/services/workflow/SUPERVISOR_IMPLEMENTATION_ROADMAP.md deleted file mode 100644 index d5af2c653..000000000 --- a/src/services/workflow/SUPERVISOR_IMPLEMENTATION_ROADMAP.md +++ /dev/null @@ -1,418 +0,0 @@ -# Workflow Supervisor Implementation Roadmap - -This roadmap is executable: each slice ends with concrete acceptance checks. -Complete slices in order. Do not implement worker or service-worker adapters until -the in-page supervisor contract is stable. - -## Goal - -Add a workflow supervisor abstraction that turns existing `$workflow` recovery -primitives into an operational recovery layer. - -The supervisor composes workflows. It does not replace `$workflow`, change command -semantics, or own durable distributed execution. - -## Non-Goals - -- Do not make workflows depend on supervisors. -- Do not make supervisors a second workflow engine. -- Do not run workflows inside service workers. -- Do not add built-in localStorage persistence in v1. -- Do not add Web Worker host/client support in v1. -- Do not implement exactly-once side effects or rollback semantics. - -## Temporal-Inspired Model - -Borrow Temporal's separation of concerns, not its distributed runtime. - -Concepts worth borrowing: - -- Workflow vs activity split: workflows coordinate modes, data, commands, - diagnostics, and history; activities are named side-effect boundaries such as - HTTP calls, storage writes, worker calls, or service-worker queue requests. -- History as evidence: supervisor recovery decisions should be based on - structured workflow history and diagnostics, not hidden mutable flags. -- Retry policy: retries should be explicit, bounded, and based on diagnostic - codes or recoverability flags. -- Cancellation: cancellation should propagate through existing workflow - `AbortSignal` support and be recorded as diagnostics/history. -- Signals and queries: workflow `send()` is the signal-like API; `snapshot()`, - `status`, and aggregate diagnostics are query-like APIs. -- Supervisor ownership: the supervisor owns persistence and recovery policy, - while workflows own command execution semantics. - -Concepts not worth borrowing for AngularTS v1: - -- Deterministic replay of workflow code. -- Durable server-side execution. -- Exactly-once side effects. -- Full event sourcing. -- Worker/task queues in core. -- Temporal-compatible APIs or terminology where AngularTS already has clearer - names. - -## Slice 1: Public Contract - -Add types only. - -- `WorkflowSupervisor` -- `WorkflowSupervisorConfig` -- `WorkflowSupervisorSnapshot` -- `WorkflowSupervisorPersistence` -- `WorkflowSupervisorRecoveryPolicy` -- `WorkflowSupervisorDiagnostic` -- `WorkflowSupervisorStatus` - -Initial API: - -```ts -supervisor.id; -supervisor.status; -supervisor.diagnostics; -supervisor.workflow(name); -supervisor.run(workflowName, commandName, input?, options?); -supervisor.send(workflowName, eventName, payload?); -supervisor.cancel(workflowName, commandName?); -supervisor.cancelAll(); -supervisor.snapshot(); -supervisor.restore(snapshot); -supervisor.persist(); -supervisor.load(); -supervisor.recover(); -``` - -Acceptance: - -- Add type tests for workflow names. -- Add type tests for command names when configs are strict. -- Add namespace exports for all public supervisor types. -- Run: - -```bash -./node_modules/.bin/tsc --project tsconfig.test.json -make test-namespace-js -``` - -## Slice 2: Service Skeleton - -Add `src/services/workflow-supervisor` or a sibling module under -`src/services/workflow/supervisor.ts`. Prefer a sibling module if it can avoid -duplicating workflow internals. - -Provider: - -```ts -class WorkflowSupervisorProvider { - $get = [ - "$workflow", - ($workflow) => (config) => createWorkflowSupervisor($workflow, config), - ]; -} -``` - -Config should accept: - -- existing workflow instances -- workflow configs that the supervisor creates through `$workflow` - -Acceptance: - -- Creates supervisor from existing workflow instances. -- Creates supervisor from workflow configs. -- Rejects duplicate/empty workflow names. -- Rejects missing supervisor id. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 3: Command Delegation - -Implement delegation without persistence. - -Rules: - -- `run(name, command, input, options)` delegates to the named workflow. -- `send(name, event, payload)` delegates to the named workflow. -- `cancel(name, command?)` delegates to one workflow. -- `cancelAll()` delegates to all workflows. -- Unknown workflow names return diagnostics or throw consistently. Prefer failed - command-style diagnostics for async operations and thrown configuration errors - for construction-time issues. - -Acceptance: - -- Delegates `run` and returns the workflow result unchanged. -- Delegates `send` and returns the workflow boolean unchanged. -- Delegates `cancel` and `cancelAll` with counts. -- Unknown workflow names produce stable supervisor diagnostics. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 4: Supervisor Snapshot - -Snapshot shape: - -```ts -{ - version: 1, - id: string, - status: "idle" | "running" | "recovering" | "failed", - workflows: { - [name: string]: WorkflowSnapshot - }, - diagnostics: WorkflowSupervisorDiagnostic[], - updatedAt: number -} -``` - -Rules: - -- Snapshot every known workflow by calling `workflow.snapshot()`. -- Snapshot supervisor diagnostics as JSON-safe values. -- `restore(snapshot)` validates version and supervisor id. -- Known workflow snapshots restore their matching workflow. -- Unknown workflow snapshot names become supervisor diagnostics. -- Missing workflow snapshot entries leave current workflows unchanged in v1. - -Acceptance: - -- Snapshots multiple workflows. -- Restores multiple workflows. -- Rejects malformed supervisor snapshots. -- Rejects mismatched supervisor ids. -- Records unknown snapshot workflow names as diagnostics. -- Leaves missing workflow snapshots unchanged. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 5: Persistence Adapter - -Adapter: - -```ts -interface WorkflowSupervisorPersistence { - load(id: string): Promise; - save(id: string, snapshot: unknown): Promise; - remove?(id: string): Promise; -} -``` - -Rules: - -- `persist()` saves `snapshot()`. -- `load()` loads and restores when a snapshot exists. -- V1 includes an IndexedDB persistence adapter as the built-in browser durable - storage option. -- Other storage backends can implement the same adapter contract. -- Persistence failures append supervisor diagnostics and rethrow only for explicit - calls. Automatic persistence should record diagnostics without breaking the - original workflow command result. - -Built-in IndexedDB adapter candidate: - -```ts -indexedDbWorkflowSupervisorPersistence({ - database: "angular-ts-workflows", - store: "supervisorSnapshots", - version: 1, -}); -``` - -IndexedDB rules: - -- One object store keyed by supervisor id is enough for v1 snapshot persistence. -- Values are stored as structured-cloneable supervisor snapshots. -- Open, upgrade, read, write, and delete failures become supervisor diagnostics. -- Do not keep transactions open across workflow command execution. -- Do not use IndexedDB as an in-memory coordination primitive. - -Acceptance: - -- Saves snapshot through adapter. -- Loads and restores snapshot through adapter. -- Handles missing persisted snapshot. -- Records save failures. -- Records load failures. -- Saves and loads through the built-in IndexedDB adapter. -- Creates the IndexedDB database and object store on first use. -- Deletes persisted snapshots when `remove()` is available and called. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 6: Persistence Policy - -Policy: - -```ts -type WorkflowSupervisorPersistPolicy = "manual" | "after-command"; -``` - -Rules: - -- Default is `"manual"`. -- `"after-command"` persists after `supervisor.run(...)` settles. -- Do not add `"after-change"` until workflows expose an explicit change - subscription API. - -Acceptance: - -- Manual policy does not save after commands. -- After-command policy saves after success. -- After-command policy saves after failed workflow results. -- Persistence failure does not replace the original command result. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 7: Recovery Policy - -Policy: - -```ts -interface WorkflowSupervisorRecoveryPolicy { - restoreOnStart?: boolean; - retryRecoverable?: boolean; -} -``` - -Rules: - -- `restoreOnStart` loads persisted state during supervisor creation only if the - service can expose an async-ready boundary. If not, defer this to an explicit - `load()` call in v1. -- `recover()` is explicit. -- `retryRecoverable` retries the latest failed command for workflows that have a - recoverable diagnostic. -- Do not auto-retry by default. - -Acceptance: - -- `recover()` does nothing by default. -- `recover()` retries recoverable failed commands only when enabled. -- Non-recoverable failures are not retried. -- Recovery diagnostics include workflow name and command name. -- Run: - -```bash -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 8: Module Registration - -Add module sugar only after service behavior is tested. - -Candidate: - -```ts -app.workflowSupervisor("sessionSupervisor", { - workflows: { - session: sessionWorkflowConfig, - sync: syncWorkflowConfig, - }, -}); -``` - -Rules: - -- Use the same dynamic config resolution pattern as `.workflow()`, `.machine()`, - `.sse()`, `.websocket()`, `.webTransport()`, `.worker()`, and `.wasm()`. -- Update Closure, ClojureScript, Dart, Gleam, Kotlin, and namespace generated - surfaces. - -Acceptance: - -- Module registration creates injectable supervisor. -- Dynamic supervisor config can resolve through DI. -- All generated language checks pass. -- Run: - -```bash -make generated-check -./node_modules/.bin/tsc --project tsconfig.test.json -``` - -## Slice 9: Documentation - -Docs must clearly distinguish the layers: - -- Workflow: command execution, diagnostics, history, snapshot/restore for one - workflow. -- Supervisor: persistence policy, recovery policy, aggregate diagnostics, - multi-workflow coordination. -- IndexedDB: v1 built-in durable browser storage for supervisor snapshots. -- Web Worker adapter: future transport for hosting workflows off the UI thread. -- Service worker: offline/cache/request-queue support, not the in-memory - supervisor. - -Acceptance: - -- Add service docs page or workflow docs section. -- Add IndexedDB persistence adapter example. -- Add multi-workflow recovery example. -- Add service-worker positioning note. -- Every executable example has a matching test. -- Run: - -```bash -make docs-examples-check -npx playwright test src/services/workflow/workflow.test.ts -``` - -## Slice 10: Future Worker Adapter - -Do not start this slice until the in-page supervisor is stable. - -Candidate APIs: - -```ts -createWorkflowWorkerHost({ workflows }); -createWorkflowWorkerClient(connection); -``` - -Rules: - -- Build on `WorkerConnection`. -- Use request ids. -- Use structured-cloneable messages. -- Publish workflow snapshots back to the page. -- Supervisor owns recovery decisions on the page side. - -Acceptance: - -- Worker-hosted workflow can run a command. -- Worker-hosted workflow can send events. -- Worker-hosted workflow publishes snapshots. -- Worker restart can be recovered by restoring a supervisor snapshot. - -## Final Readiness Gate - -Run before marking the supervisor production-ready: - -```bash -make check -make coverage-check -npx playwright test src/services/workflow/workflow.test.ts -``` - -Supervisor is production-ready only when: - -- public types are strict by default -- all examples are test-backed -- generated language facades are fresh -- restore and persistence failures are deterministic -- no supervisor behavior depends on service worker lifetime -- worker support, if added, remains a transport adapter diff --git a/src/services/workflow/workflow-types.spec.ts b/src/services/workflow/workflow-types.spec.ts index 51c396f41..0d760a917 100644 --- a/src/services/workflow/workflow-types.spec.ts +++ b/src/services/workflow/workflow-types.spec.ts @@ -1,16 +1,45 @@ /// -import { defineCommand, defineWorkflow } from "./workflow.ts"; +import { + createWorkflowWorkerClient, + createWorkflowWorkerHost, + defineCommand, + defineWorkflow, +} from "./workflow.ts"; import type { Workflow, WorkflowCommand, WorkflowCommandMap, WorkflowCommandContext, WorkflowCommandResult, + WorkflowCommandStatus, WorkflowConfig, WorkflowDiagnostic, WorkflowNoCommands, + WorkflowNoEvents, WorkflowService, + WorkflowSendResult, WorkflowSnapshot, + WorkflowStateEngine, + WorkflowStateEngineContext, + WorkflowStateEngineFactory, + WorkflowStateEngineSnapshot, + WorkflowSupervisor, + WorkflowSupervisorConfig, + WorkflowSupervisorDiagnostic, + WorkflowSupervisorIndexedDbPersistenceConfig, + WorkflowSupervisorPersistence, + WorkflowSupervisorPersistencePolicy, + WorkflowSupervisorRecoveryPolicy, + WorkflowSupervisorSnapshot, + WorkflowSupervisorStatus, + WorkflowWorkerClient, + WorkflowWorkerHost, + WorkflowWorkerHostConfig, + WorkflowWorkerMessage, + WorkflowWorkerRequest, + WorkflowWorkerRequestOperation, + WorkflowWorkerResponse, + WorkflowWorkerSnapshotMessage, } from "./workflow.ts"; interface BuildData { @@ -25,34 +54,225 @@ interface BuildEvents { } interface BuildCommands { - build: WorkflowCommand< - BuildData, - string, - { file: string }, - BuildEvents, - WorkflowNoCommands, - "build" - >; + build: WorkflowCommand; } interface PipelineCommands { - build: WorkflowCommand< - BuildData, - string, - { file: string }, - BuildEvents, - PipelineCommands - >; + build: WorkflowCommand; publish: WorkflowCommand< - BuildData, { file: string }, { url: string }, - BuildEvents, - PipelineCommands + BuildData, + BuildEvents >; } describe("$workflow types", () => { + it("limits command statuses to settled outcomes", () => { + const completed: WorkflowCommandStatus = "completed"; + const cancelled: WorkflowCommandStatus = "cancelled"; + const invalidResult: WorkflowCommandResult = { + ok: false, + // @ts-expect-error queued work settles before run returns a result. + status: "queued", + diagnostics: [], + }; + + expect(completed).toBe("completed"); + expect(cancelled).toBe("cancelled"); + expect(invalidResult.ok).toBeFalse(); + }); + + it("infers direct command-map input and output without command map generics", async () => { + const workflowService = null as unknown as WorkflowService; + const config = defineWorkflow({ + id: "direct-inferred-command", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + build({ input }: WorkflowCommandContext) { + return { + file: input, + }; + }, + }, + }); + const workflow = workflowService(config); + const result = await workflow.run("build", "index.html"); + + // @ts-expect-error inferred direct commands preserve input type. + workflow.run("build", 123); + // @ts-expect-error inferred direct command maps reject unknown commands. + workflow.run("publish", "index.html"); + + if (result.ok) { + const file: string | undefined = result.output?.file; + // @ts-expect-error inferred direct commands preserve output type. + const wrongFile: number | undefined = result.output?.file; + + void file; + void wrongFile; + } + + expect(result.ok).toBe(true); + }); + + it("infers simple command input and output without defineCommand generic lists", async () => { + const workflowService = null as unknown as WorkflowService; + const buildCommand = defineCommand( + ({ input }: WorkflowCommandContext) => { + return { + file: input, + }; + }, + ); + type InferredCommands = { + build: typeof buildCommand; + }; + const config = defineWorkflow< + { output: string }, + WorkflowNoEvents, + InferredCommands + >({ + id: "inferred-command", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + build: buildCommand, + }, + }); + const workflow = workflowService(config); + const result = await workflow.run("build", "index.html"); + + // @ts-expect-error build requires string input inferred from the context. + workflow.run("build", 123); + // @ts-expect-error inferred workflows reject unknown command names. + workflow.run("publish", "index.html"); + + if (result.ok) { + const file: string | undefined = result.output?.file; + // @ts-expect-error inferred command output preserves the file type. + const wrongFile: number | undefined = result.output?.file; + + void file; + void wrongFile; + } + + expect(result.ok).toBe(true); + }); + + it("uses one context-object command definition", async () => { + const workflowService = null as unknown as WorkflowService; + const buildCommand = defineCommand< + string, + { file: string }, + BuildData, + BuildEvents + >(({ input, ...context }) => { + const runningCommand: string = context.command; + const aborted: boolean = context.signal.aborted; + + context.cleanup(() => undefined); + context.data.output = input; + + void runningCommand; + void aborted; + + return { + file: context.data.output, + }; + }); + const workflow = workflowService( + defineWorkflow({ + id: "context-command", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: {}, + }, + commands: { + build: buildCommand, + }, + }), + ); + + const result = await workflow.run("build", "index.html"); + + if (result.ok) { + const file: string | undefined = result.output?.file; + // @ts-expect-error context command output preserves the file type. + const wrongFile: number | undefined = result.output?.file; + + void file; + void wrongFile; + } + + // @ts-expect-error context command preserves its input type. + workflow.run("build", { file: "index.html" }); + + expect(result.ok).toBe(true); + }); + + it("accepts direct context-object workflow command map handlers", async () => { + const workflowService = null as unknown as WorkflowService; + const config = defineWorkflow({ + id: "direct-context-command", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: {}, + }, + commands: { + build({ input, data, command, signal }) { + const fileInput: string = input; + const commandName: string = command; + const aborted: boolean = signal.aborted; + + data.output = fileInput; + + void commandName; + void aborted; + + return { + file: data.output, + }; + }, + }, + }); + const workflow = workflowService(config); + const result = await workflow.run("build", "index.html"); + + // @ts-expect-error direct context commands preserve input type. + workflow.run("build", 123); + + if (result.ok) { + const file: string | undefined = result.output?.file; + // @ts-expect-error direct context commands preserve output type. + const wrongFile: number | undefined = result.output?.file; + + void file; + void wrongFile; + } + + expect(result.ok).toBe(true); + }); + it("typechecks strict workflow definitions by default", async () => { const workflowService = null as unknown as WorkflowService; const strictConfig = defineWorkflow({ @@ -64,23 +284,29 @@ describe("$workflow types", () => { output: "", error: "", } satisfies BuildData, - transitions: { + states: { idle: { - start() { - return "running"; + on: { + start: { + to: "running", + }, }, }, running: { - complete(data, payload) { - data.output = payload.output; - - return "complete"; + on: { + complete: { + to: "complete", + update({ data, payload }) { + data.output = payload.output; + }, + }, }, }, + complete: {}, }, commands: { - build: defineCommand( - ({ cleanup, command, data, input, signal }) => { + build: defineCommand( + ({ input, cleanup, command, data, signal }) => { const runningCommand: string = command; const aborted: boolean = signal.aborted; @@ -111,16 +337,18 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: { + states: { idle: { - // @ts-expect-error strict workflow definitions reject unknown transition keys. - missing() { - return "running"; + on: { + // @ts-expect-error strict workflow definitions reject unknown event keys. + missing: { + to: "running", + }, }, }, }, commands: { - build: defineCommand( + build: defineCommand( () => ({ ok: true, output: { @@ -141,16 +369,22 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: { + states: { running: { - // @ts-expect-error complete receives an object payload, not a string. - complete(_data, _payload: string) { - return "complete"; + on: { + complete: { + to: "complete", + // @ts-expect-error complete receives an object payload, not a string. + update({ payload }: { payload: string }) { + void payload; + }, + }, }, }, + complete: {}, }, commands: { - build: defineCommand( + build: defineCommand( () => ({ ok: true, output: { @@ -171,30 +405,24 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { - build: defineCommand< - BuildData, - string, - { file: string }, - BuildEvents, - WorkflowNoCommands, - "build" - >(({ command, input }) => { - const name: "build" = command; - // @ts-expect-error command is the literal command name when typed. - const wrongName: "publish" = command; + build: defineCommand( + ({ input, command }) => { + const name: string = command; - void name; - void wrongName; + void name; - return { - ok: true, - output: { - file: input, - }, - }; - }), + return { + ok: true, + output: { + file: input, + }, + }; + }, + ), }, }); const strictWorkflow = workflowService(strictConfig); @@ -203,10 +431,13 @@ describe("$workflow types", () => { id: "empty", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, }), ); const result = await strictWorkflow.run("build", "index.html"); + const currentMode: string = strictWorkflow.current; strictWorkflow.send("start"); strictWorkflow.send("complete", { output: "site" }); @@ -224,6 +455,8 @@ describe("$workflow types", () => { strictWorkflow.run("missing", "index.html"); // @ts-expect-error strict workflows reject unknown retry command names. strictWorkflow.retry("missing"); + // @ts-expect-error workflow mode is readonly; use send() or restore(). + strictWorkflow.current = "running"; // @ts-expect-error workflows without typed events expose no sends by default. noCommandWorkflow.send("start"); // @ts-expect-error workflows without typed commands expose no run commands by default. @@ -233,6 +466,7 @@ describe("$workflow types", () => { expect(configWithUnknownTransition.id).toBe("docs-build"); expect(configWithWrongPayload.id).toBe("docs-build"); expect(commandNameConfig.id).toBe("docs-build"); + expect(currentMode).toBeDefined(); }); it("typechecks dynamic workflow command maps with unknown input", async () => { @@ -248,9 +482,14 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { - build: ({ data, input }) => { + build: ({ + data, + input, + }: WorkflowCommandContext) => { // @ts-expect-error dynamic command input is unknown until narrowed. data.output = input; @@ -284,7 +523,9 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: {}, + states: { + idle: {}, + }, }); const nonCallableCommand = defineWorkflow< BuildData, @@ -299,7 +540,9 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { // @ts-expect-error workflow command entries must be callable. build: "index.html", @@ -316,21 +559,19 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: {}, + states: { + idle: {}, + }, // @ts-expect-error command maps require every declared command key. commands: { - build: defineCommand< - BuildData, - string, - { file: string }, - BuildEvents, - PipelineCommands - >(() => ({ - ok: true, - output: { - file: "index.html", - }, - })), + build: defineCommand( + () => ({ + ok: true, + output: { + file: "index.html", + }, + }), + ), }, }); @@ -339,7 +580,7 @@ describe("$workflow types", () => { expect(incompleteCommands.id).toBe("docs-pipeline"); }); - it("typechecks command contexts against the full command map", () => { + it("typechecks reusable command contexts without command-map generics", () => { const config = defineWorkflow({ id: "docs-pipeline", initial: "idle", @@ -347,41 +588,95 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: { + states: { idle: { - start() { - return "running"; + on: { + start: { + to: "running", + }, }, }, + running: {}, }, commands: { - build: defineCommand< - BuildData, - string, + build: defineCommand( + ({ input, workflow, command }) => { + const buildCommand: string = command; + + void buildCommand; + + workflow.send("start"); + workflow.run("publish", { file: input }); + + return { + ok: true, + output: { + file: input, + }, + }; + }, + ), + publish: defineCommand< { file: string }, - BuildEvents, - PipelineCommands - >(({ workflow, input }) => { - workflow.send("start"); - workflow.run("publish", { file: input }); - // @ts-expect-error publish requires a file object. - workflow.run("publish", input); - // @ts-expect-error typed command context rejects unknown commands. - workflow.run("missing", { file: input }); + { url: string }, + BuildData, + BuildEvents + >(({ input }) => ({ + ok: true, + output: { + url: `/docs/${input.file}`, + }, + })), + }, + }); + const workflowService = null as unknown as WorkflowService; + const workflow = workflowService(config); + const publishResult = workflow.run("publish", { file: "index.html" }); - return { - ok: true, - output: { - file: input, + // @ts-expect-error publish requires a file object. + workflow.run("publish", "index.html"); + + expect(config.id).toBe("docs-pipeline-explicit"); + expect(publishResult).toBeDefined(); + }); + + it("keeps explicit command generics available for package authors", () => { + const config = defineWorkflow({ + id: "docs-pipeline-explicit", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: { + on: { + start: { + to: "running", }, - }; - }), + }, + }, + running: {}, + }, + commands: { + build: defineCommand( + ({ input, workflow }) => { + workflow.send("start"); + workflow.run("publish", { file: input }); + + return { + ok: true, + output: { + file: input, + }, + }; + }, + ), publish: defineCommand< - BuildData, { file: string }, { url: string }, - BuildEvents, - PipelineCommands + BuildData, + BuildEvents >(({ input }) => ({ ok: true, output: { @@ -415,33 +710,42 @@ describe("$workflow types", () => { output: "", error: "", }, - transitions: { + states: { idle: { - start() { - return "running"; + on: { + start: { + to: "running", + }, }, }, running: { - complete(data, payload) { - data.output = payload.output; - - return "complete"; - }, - fail(data, reason) { - data.error = reason; - - return "failed"; + on: { + complete: { + to: "complete", + update({ data, payload }) { + data.output = payload.output; + }, + }, + fail: { + to: "failed", + update({ data, payload }) { + data.error = payload; + }, + }, }, }, + complete: {}, + failed: {}, }, commands: { build( - context: WorkflowCommandContext, + context: WorkflowCommandContext, ): WorkflowCommandResult<{ file: string }> { context.data.output = context.input; return { ok: true, + status: "completed", output: { file: context.data.output, }, @@ -459,11 +763,6 @@ describe("$workflow types", () => { BuildEvents, BuildCommands > = workflow; - const namespaceConfig: ng.WorkflowConfig< - BuildData, - BuildEvents, - BuildCommands - > = config; workflow.restore(snapshot); workflow.send("start"); @@ -478,7 +777,6 @@ describe("$workflow types", () => { const result = await namespaceWorkflow.run("build", "index.html"); const retryResult = await namespaceWorkflow.retry("build"); - const repeatResult = await namespaceWorkflow.repeat("build"); // @ts-expect-error build requires string input. namespaceWorkflow.run("build", 123); @@ -487,10 +785,9 @@ describe("$workflow types", () => { // @ts-expect-error typed workflows reject unknown retry commands. namespaceWorkflow.retry("missing"); - expect(namespaceConfig.id).toBe("docs-build"); + expect(config.id).toBe("docs-build"); expect(result.ok).toBe(true); expect(retryResult.ok).toBe(true); - expect(repeatResult.ok).toBe(true); if (result.ok) { const file: string | undefined = result.output?.file; @@ -504,47 +801,640 @@ describe("$workflow types", () => { } }); - it("typechecks module.workflow registration", () => { - const module = null as unknown as ng.NgModule; - const config: ng.WorkflowConfig = { + it("typechecks custom workflow state engines", () => { + const engineFactory: WorkflowStateEngineFactory< + BuildData, + BuildEvents, + BuildCommands + > = ( + context: WorkflowStateEngineContext< + BuildData, + BuildEvents, + BuildCommands + >, + ) => { + const defaultEngine: WorkflowStateEngine = + context.createDefaultEngine(); + const initial: string = context.config.initial; + const data: BuildData = context.config.data; + const command = context.config.commands.build; + + void initial; + void data; + void command; + + return defaultEngine; + }; + const config = defineWorkflow({ id: "docs-build", initial: "idle", data: { output: "", error: "", }, - transitions: {}, + stateEngine: engineFactory, + states: { + idle: { + on: { + start: { + to: "running", + }, + }, + }, + running: {}, + }, + commands: { + build: defineCommand( + ({ workflow }) => { + workflow.send("start"); + + return { + ok: true, + output: { + file: "index.html", + }, + }; + }, + ), + }, + }); + const namespaceEngine: WorkflowStateEngine = + engineFactory({ + config, + createDefaultEngine() { + return { + current: "idle", + data: config.data, + send(type, ...payload) { + return { + ok: true, + status: "transitioned", + type, + from: "idle", + to: "idle", + data: config.data, + payload: payload[0], + }; + }, + can() { + return true; + }, + matches(mode) { + return mode === "idle"; + }, + snapshot() { + return { + current: "idle", + data: config.data, + }; + }, + restore() { + return undefined; + }, + }; + }, + }); + + namespaceEngine.send("start"); + // @ts-expect-error strict custom engines reject unknown events. + namespaceEngine.send("missing"); + // @ts-expect-error start does not accept a payload. + namespaceEngine.send("start", {}); + + const namespaceSnapshot: WorkflowStateEngineSnapshot = + namespaceEngine.snapshot(); + const namespaceSendResult: WorkflowSendResult = + namespaceEngine.send("start"); + const namespaceNoEvents: WorkflowNoEvents = {}; + + expect(config.stateEngine).toBe(engineFactory); + expect(namespaceEngine.current).toBe("idle"); + expect(namespaceSnapshot.current).toBe("idle"); + expect(namespaceSendResult.status).toBe("transitioned"); + expect(namespaceNoEvents).toEqual({}); + }); + + it("typechecks workflow state tree configs", () => { + const config: WorkflowConfig = { + id: "state-build", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data }) { + data.output = "running"; + }, + }, + complete: { + // @ts-expect-error complete requires an output payload. + update({ payload }: { payload: string }) { + void payload; + }, + }, + }, + }, + running: { + on: { + complete: { + to: "complete", + update({ data, payload }) { + data.output = payload.output; + }, + }, + }, + }, + complete: {}, + }, }; + const workflowService = null as unknown as WorkflowService; + const workflow = workflowService(config); - const returnedModule = module.workflow( - "docsWorkflow", - config, - ); + workflow.send("start"); + workflow.send("complete", { output: "site" }); + // @ts-expect-error typed state-tree workflows reject unknown events. + workflow.send("missing"); + + expect(config.id).toBe("state-build"); + }); + + it("typechecks inferred module.workflow registration", () => { + const module = null as unknown as ng.NgModule; + const config = defineWorkflow({ + id: "docs-build", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: {}, + }, + commands: { + build(input: string) { + return { + file: input, + }; + }, + }, + }); + + const returnedModule = module.workflow("docsWorkflow", config); expect(returnedModule).toBe(module); }); - it("typechecks module.workflow registration from resolvable config", () => { + it("typechecks inferred module.workflow registration from resolvable config", () => { const module = null as unknown as ng.NgModule; - const buildWorkflowConfig = (): ng.WorkflowConfig< - BuildData, - BuildEvents - > => ({ + const buildWorkflowConfig = () => + defineWorkflow({ + id: "docs-build", + initial: "idle", + data: { + output: "", + error: "", + }, + states: { + idle: {}, + }, + commands: { + build(input: string) { + return { + file: input, + }; + }, + }, + }); + + const returnedModule = module.workflow("docsWorkflow", buildWorkflowConfig); + + expect(returnedModule).toBe(module); + }); + + it("typechecks inferred module.workflowSupervisor registration", () => { + const module = null as unknown as ng.NgModule; + const build = defineWorkflow({ + id: "supervised-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + build(input: string) { + return { + file: input, + }; + }, + }, + }); + const definition = { + id: "docs-supervisor", + workflows: { + build, + }, + }; + + const direct = module.workflowSupervisor("docsSupervisor", definition); + const resolved = module.workflowSupervisor( + "resolvedDocsSupervisor", + () => definition, + ); + + expect(direct).toBe(module); + expect(resolved).toBe(module); + }); + + it("typechecks the public workflow supervisor contract", async () => { + const buildConfig = defineWorkflow({ id: "docs-build", initial: "idle", data: { output: "", error: "", }, - transitions: {}, + states: { + idle: { + on: { + start: { + to: "running", + }, + }, + }, + running: {}, + }, + commands: { + build: defineCommand( + ({ input }) => ({ + ok: true, + output: { + file: input, + }, + }), + ), + }, }); + const buildWorkflow = { + id: "docs-build", + } as Workflow; + const publishWorkflow = { + id: "docs-publish", + } as Workflow; + type SupervisorWorkflows = { + build: typeof buildConfig; + publish: typeof publishWorkflow; + }; + const diagnostic: WorkflowSupervisorDiagnostic = { + code: "supervisor.missingWorkflow", + message: "Workflow is not registered.", + recoverable: true, + workflow: "missing", + command: "build", + }; + const status: WorkflowSupervisorStatus = "idle"; + const recovery: WorkflowSupervisorRecoveryPolicy = { + restoreOnStart: false, + }; + const persistencePolicy: WorkflowSupervisorPersistencePolicy = + "after-command"; + const indexedDbPersistenceConfig: WorkflowSupervisorIndexedDbPersistenceConfig = + { + database: "angular-ts-workflows", + store: "supervisorSnapshots", + version: 1, + }; + const snapshot: WorkflowSupervisorSnapshot<{ + build: WorkflowSnapshot; + publish: WorkflowSnapshot; + }> = { + version: 1, + id: "docs-supervisor", + status: "idle", + workflows: { + build: { + version: 1, + id: "docs-build", + current: "idle", + data: { + output: "", + error: "", + }, + diagnostics: [], + history: [], + }, + publish: { + version: 1, + id: "docs-publish", + current: "idle", + data: { + output: "", + error: "", + }, + diagnostics: [], + history: [], + }, + }, + diagnostics: [diagnostic], + updatedAt: 1, + }; + const persistence: WorkflowSupervisorPersistence = { + async load(id) { + expect(id).toBe("docs-supervisor"); - const returnedModule = module.workflow( - "docsWorkflow", - buildWorkflowConfig, - ); + return snapshot; + }, + async save(id, savedSnapshot) { + expect(id).toBe("docs-supervisor"); + expect(savedSnapshot.id).toBe("docs-supervisor"); + }, + }; + const config: WorkflowSupervisorConfig = { + id: "docs-supervisor", + workflows: { + build: buildConfig, + publish: publishWorkflow, + }, + persistence, + persistencePolicy, + recovery, + }; + const supervisor = { + id: "docs-supervisor", + status, + diagnostics: [diagnostic], + ready: Promise.resolve(snapshot), + workflow(name: "build" | "publish") { + return name === "build" ? buildWorkflow : publishWorkflow; + }, + async run(_workflowName: string, command: string) { + if (command === "publish") { + return { + ok: true, + status: "completed", + output: { + url: "/docs/index.html", + }, + }; + } - expect(returnedModule).toBe(module); + return { + ok: true, + status: "completed", + output: { + file: "index.html", + }, + }; + }, + send() { + return { + ok: true, + status: "transitioned", + type: "start", + }; + }, + cancel() { + return 1; + }, + cancelAll() { + return 2; + }, + snapshot() { + return snapshot; + }, + restore() { + return undefined; + }, + async persist() { + return snapshot; + }, + async load() { + return snapshot; + }, + async recover() { + return snapshot; + }, + } as WorkflowSupervisor; + const namespaceConfig: ng.WorkflowSupervisorConfig = + config; + const buildWorkflowRef = supervisor.workflow("build"); + const publishWorkflowRef = supervisor.workflow("publish"); + const buildHandleResult = await buildWorkflowRef.run("build", "index.html"); + const publishHandleResult = await publishWorkflowRef.run("publish", { + file: "index.html", + }); + const buildResult = await supervisor + .workflow("build") + .run("build", "index.html"); + const publishResult = await supervisor.workflow("publish").run("publish", { + file: "index.html", + }); + + supervisor.workflow("build").send("start"); + supervisor.workflow("build").cancel("build"); + buildWorkflowRef.send("start"); + supervisor.cancelAll(); + supervisor.restore(snapshot); + await supervisor.ready; + await supervisor.persist(); + await supervisor.load(); + await supervisor.recover(); + + // @ts-expect-error supervisors reject unknown workflow names. + supervisor.workflow("missing"); + // @ts-expect-error start does not accept a payload. + supervisor.workflow("build").send("start", {}); + + expect(buildWorkflowRef.id).toBe("docs-build"); + expect(publishWorkflowRef.id).toBe("docs-publish"); + expect(namespaceConfig.id).toBe("docs-supervisor"); + expect(diagnostic.code).toBe("supervisor.missingWorkflow"); + expect(indexedDbPersistenceConfig.store).toBe("supervisorSnapshots"); + expect(persistencePolicy).toBe("after-command"); + expect(status).toBe("idle"); + expect(buildHandleResult.ok).toBe(true); + expect(publishHandleResult.ok).toBe(true); + expect(buildResult.ok).toBe(true); + expect(publishResult.ok).toBe(true); + + if (buildHandleResult.ok) { + const handleFile: string | undefined = buildHandleResult.output?.file; + + expect(handleFile).toBe("index.html"); + } + + if (publishHandleResult.ok) { + const handleUrl: string | undefined = publishHandleResult.output?.url; + + expect(handleUrl).toBe("/docs/index.html"); + } + + if (buildResult.ok) { + const file: string | undefined = buildResult.output?.file; + + // @ts-expect-error supervisor command output preserves result type. + const numericFile: number | undefined = buildResult.output?.file; + + void numericFile; + + expect(file).toBe("index.html"); + } + }); + + it("typechecks the workflow worker adapter contract", async () => { + const buildWorkflow = { + id: "docs-build", + current: "idle", + data: { + output: "", + error: "", + }, + diagnostics: [], + history: [], + send() { + return { + ok: true, + status: "transitioned", + type: "start", + data: { + output: "", + error: "", + }, + }; + }, + can() { + return true; + }, + matches() { + return true; + }, + async run() { + return { + ok: true, + status: "completed", + output: { + file: "index.html", + }, + }; + }, + async retry() { + return { + ok: true, + status: "completed", + output: { + file: "index.html", + }, + }; + }, + cancel() { + return 0; + }, + snapshot() { + return { + version: 1, + id: "docs-build", + current: "idle", + data: { + output: "", + error: "", + }, + diagnostics: [], + history: [], + }; + }, + restore() { + return undefined; + }, + } as Workflow; + const hostConfig: WorkflowWorkerHostConfig<{ + build: typeof buildWorkflow; + }> = { + workflows: { + build: buildWorkflow, + }, + }; + const host: WorkflowWorkerHost = createWorkflowWorkerHost(hostConfig); + const requestOperation: WorkflowWorkerRequestOperation = "run"; + const request: WorkflowWorkerRequest = { + type: "angular-ts:workflow-worker:request", + id: "request-1", + operation: requestOperation, + workflow: "build", + command: "build", + input: "index.html", + }; + const response: WorkflowWorkerResponse< + WorkflowCommandResult<{ file: string }> + > = { + type: "angular-ts:workflow-worker:response", + id: "request-1", + ok: true, + result: { + ok: true, + status: "completed", + output: { + file: "index.html", + }, + }, + }; + const snapshotMessage: WorkflowWorkerSnapshotMessage = { + type: "angular-ts:workflow-worker:snapshot", + snapshot: { + build: buildWorkflow.snapshot(), + }, + }; + const message: WorkflowWorkerMessage = request; + const connection: ng.WorkerConnection = { + postMessage() { + return undefined; + }, + onMessage() { + return () => undefined; + }, + restart() { + return undefined; + }, + terminate() { + return undefined; + }, + }; + const client: WorkflowWorkerClient = createWorkflowWorkerClient(connection); + const namespaceClient: WorkflowWorkerClient = client; + const namespaceHost: WorkflowWorkerHost = host; + const namespaceRequest: WorkflowWorkerRequest = request; + const namespaceResponse: WorkflowWorkerResponse< + WorkflowCommandResult<{ file: string }> + > = response; + + // @ts-expect-error request operation rejects unknown operations. + const invalidOperation: WorkflowWorkerRequestOperation = "cancel"; + const invalidRequest: WorkflowWorkerRequest = { + // @ts-expect-error request type must use the workflow worker request tag. + type: "request", + id: "request-2", + operation: "run", + }; + + void invalidOperation; + void invalidRequest; + void snapshotMessage; + void message; + void namespaceHost; + void namespaceRequest; + void namespaceResponse; + + expect(host.snapshot().build.id).toBe("docs-build"); + expect(namespaceClient.latestSnapshot).toBeUndefined(); + namespaceClient.dispose(); + await expectAsync( + namespaceClient.run<{ file: string }>("build", "build", "index.html"), + ).toBeRejected(); }); }); diff --git a/src/services/workflow/workflow-ui-fragment.ts b/src/services/workflow/workflow-ui-fragment.ts new file mode 100644 index 000000000..bbc9b6447 --- /dev/null +++ b/src/services/workflow/workflow-ui-fragment.ts @@ -0,0 +1,88 @@ +import { + disposeCompiledFragmentRecord, + getCompiledFragmentRecord, + replaceCompiledFragmentNodes, + type CompiledFragmentRecord, +} from "../../core/compile/incremental-fragment.ts"; + +export interface WorkflowUiFragmentHostOptions { + compile: ng.CompileService; + scope: ng.Scope; + target: Element; +} + +export interface WorkflowUiFragmentHost { + readonly current: CompiledFragmentRecord | null; + dispose(): void; + render(template: string | Node | NodeList): CompiledFragmentRecord; +} + +/** + * Internal workflow UI bridge for progress, recovery, and diagnostics surfaces. + */ +export function createWorkflowUiFragmentHost( + options: WorkflowUiFragmentHostOptions, +): WorkflowUiFragmentHost { + let current: CompiledFragmentRecord | null = null; + let disposed = false; + + const host: WorkflowUiFragmentHost = { + get current() { + return current; + }, + dispose() { + if (disposed) { + return; + } + + disposed = true; + removeDestroyListener(); + + if (current && !current.disposed) { + disposeCompiledFragmentRecord(current); + } + + current = null; + }, + render(template) { + if (disposed) { + throw new Error("Cannot render a disposed workflow UI fragment host."); + } + + const linked = options.compile(template)(options.scope); + const nodes = normalizeLinkedNodes(linked); + const next = findCompiledFragmentRecord(nodes); + + replaceCompiledFragmentNodes(options.target, next); + current = next; + + return next; + }, + }; + + const removeDestroyListener = options.scope.$on("$destroy", () => { + host.dispose(); + }); + + return host; +} + +function normalizeLinkedNodes( + linked: Element | Node | ChildNode | Node[], +): Node[] { + return Array.isArray(linked) ? linked : [linked]; +} + +function findCompiledFragmentRecord( + nodes: readonly Node[], +): CompiledFragmentRecord { + for (const node of nodes) { + const fragment = getCompiledFragmentRecord(node); + + if (fragment) { + return fragment; + } + } + + throw new Error("Workflow UI fragment host requires a compiled fragment."); +} diff --git a/src/services/workflow/workflow.spec.ts b/src/services/workflow/workflow.spec.ts index 6fc137b38..83c1566ad 100644 --- a/src/services/workflow/workflow.spec.ts +++ b/src/services/workflow/workflow.spec.ts @@ -1,14 +1,83 @@ // @ts-nocheck /// import { Angular } from "../../angular.ts"; +import { + getCompiledFragmentRecord, + scheduleCompiledFragmentDomWork, +} from "../../core/compile/incremental-fragment.ts"; import { createInjector } from "../../core/di/injector.ts"; import { wait } from "../../shared/test-utils.ts"; -import { defineCommand, defineWorkflow } from "./workflow.ts"; -import type { WorkflowService } from "./workflow.ts"; +import { + createWorkflowSupervisor, + createWorkflowWorkerClient, + createWorkflowWorkerHost, + defineCommand, + defineWorkflow, + indexedDbWorkflowSupervisorPersistence, + createWorkflowService, +} from "./workflow.ts"; +import type { MachineConfig } from "../machine/machine.ts"; +import type { + WorkflowCommandOptions, + WorkflowConfig, + WorkflowService, + WorkflowSupervisorPersistence, + WorkflowWorkerHost, + WorkflowWorkerHostConfig, + WorkflowWorkerRequest, +} from "./workflow.ts"; +import { createWorkflowUiFragmentHost } from "./workflow-ui-fragment.ts"; + +function deleteIndexedDbDatabase(database: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(database); + + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error ?? new Error("IndexedDB delete failed.")); + }; + request.onblocked = () => { + resolve(); + }; + }); +} + +function createWorkflowWorkerTestConnection( + host: WorkflowWorkerHost, +): ng.WorkerConnection & { dispatch(data: unknown): void } { + const listeners = new Set<(data: unknown, event: MessageEvent) => void>(); + const dispatch = (data: unknown) => { + const event = { data } as MessageEvent; + + for (const listener of listeners) { + listener(data, event); + } + }; + + return { + dispatch, + postMessage(data: unknown) { + void host.handle(data, dispatch); + }, + onMessage(listener) { + listeners.add(listener); + + return () => listeners.delete(listener); + }, + restart() { + return undefined; + }, + terminate() { + return undefined; + }, + }; +} describe("$workflow", () => { let $compile: ng.CompileService; - let $rootScope: ng.RootScopeService; + let $rootScope: ng.Scope; let $workflow: WorkflowService; beforeEach(() => { @@ -17,11 +86,11 @@ describe("$workflow", () => { const injector = createInjector(["ng"]); $compile = injector.get("$compile") as ng.CompileService; - $rootScope = injector.get("$rootScope") as ng.RootScopeService; + $rootScope = injector.get("$rootScope") as ng.Scope; $workflow = injector.get("$workflow") as WorkflowService; }); - it("creates a reactive workflow on top of machine transitions", async () => { + it("creates a reactive workflow on top of machine-backed state trees", async () => { const element = $compile( '
{{ build.current }}' + '{{ build.data.status }}
', @@ -33,143 +102,2731 @@ describe("$workflow", () => { data: { status: "idle", }, - transitions: { + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.status = "running"; + }, + }, + }, + }, + }, + }); + + await wait(); + + expect(element.querySelector(".mode")?.textContent).toBe("idle"); + expect(element.querySelector(".status")?.textContent).toBe("idle"); + + $rootScope.build.send("start"); + + await wait(); + + expect(element.querySelector(".mode")?.textContent).toBe("running"); + expect(element.querySelector(".status")?.textContent).toBe("running"); + }); + + it("creates a reactive workflow on top of machine state trees", async () => { + const element = $compile( + '
{{ build.current }}' + + '{{ build.data.status }}
', + )($rootScope); + + $rootScope.build = $workflow({ + id: "state-build", + initial: "idle", + data: { + status: "idle", + }, + states: { idle: { - start(data) { - data.status = "running"; + on: { + start: { + to: "running", + update({ data }) { + data.status = "running"; + + return false; + }, + }, + }, + }, + running: {}, + }, + }); + + await wait(); + + expect(element.querySelector(".mode")?.textContent).toBe("idle"); + expect(element.querySelector(".status")?.textContent).toBe("idle"); + + $rootScope.build.send("start"); + + await wait(); + + expect(element.querySelector(".mode")?.textContent).toBe("running"); + expect(element.querySelector(".status")?.textContent).toBe("running"); + }); + + it("returns typed workflow definitions and commands unchanged", () => { + const command = defineCommand(({ input }) => ({ + ok: true, + output: String(input), + })); + const config = { + id: "defined", + initial: "idle", + data: { + value: "", + }, + states: { + idle: {}, + }, + commands: { + publish: command, + }, + }; + + expect(typeof command).toBe("function"); + expect(defineWorkflow(config)).toBe(config); + }); + + it("supports context-object workflow command definitions", async () => { + const command = defineCommand(({ input, data, command }) => { + data.value = `${command}:${input}`; + + return { + ok: true, + output: data.value, + }; + }); + const workflow = $workflow({ + id: "context-command", + initial: "idle", + data: { + value: "", + }, + states: { + idle: {}, + }, + commands: { + publish: command, + }, + }); + + const result = await workflow.run("publish", "ready"); + + expect(result).toEqual({ + ok: true, + status: "completed", + output: "publish:ready", + diagnostics: undefined, + }); + expect(workflow.data.value).toBe("publish:ready"); + }); + + it("supports direct context-object workflow command maps", async () => { + const workflow = $workflow({ + id: "direct-context-command", + initial: "idle", + data: { + value: "", + }, + states: { + idle: {}, + }, + commands: { + publish({ input, data, command }) { + data.value = `${command}:${input}`; + + return { + ok: true, + output: data.value, + }; + }, + }, + }); + + const result = await workflow.run("publish", "ready"); + + expect(result).toEqual({ + ok: true, + status: "completed", + output: "publish:ready", + diagnostics: undefined, + }); + expect(workflow.data.value).toBe("publish:ready"); + }); + + it("validates workflow configuration at creation time", () => { + expect(() => $workflow(null as WorkflowConfig)).toThrowError( + "$workflow requires a config object.", + ); + expect(() => + $workflow({ + id: "", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }), + ).toThrowError("$workflow requires a non-empty id."); + expect(() => + $workflow({ + id: "bad-initial", + initial: "", + data: {}, + states: { + idle: {}, + }, + }), + ).toThrowError("$workflow requires a non-empty initial mode."); + expect(() => + $workflow({ + id: "bad-data", + initial: "idle", + data: null, + states: { + idle: {}, + }, + }), + ).toThrowError("$workflow requires a data object."); + expect(() => + $workflow({ + id: "missing-states", + initial: "idle", + data: {}, + }), + ).toThrowError("$workflow requires a states object."); + expect(() => + $workflow({ + id: "bad-states", + initial: "idle", + data: {}, + states: null, + }), + ).toThrowError("$workflow requires a states object."); + expect(() => + $workflow({ + id: "bad-commands", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: null, + }), + ).toThrowError("$workflow commands must be an object."); + expect(() => + $workflow({ + id: "bad-concurrency", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + concurrency: "drop", + }), + ).toThrowError( + "$workflow concurrency must be 'allow', 'reject', or 'queue'.", + ); + expect(() => + $workflow({ + id: "bad-history-limit", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + historyLimit: -1, + }), + ).toThrowError("$workflow historyLimit must be a non-negative integer."); + expect(() => + $workflow({ + id: "bad-diagnostic-limit", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + diagnosticLimit: Number.POSITIVE_INFINITY, + }), + ).toThrowError("$workflow diagnosticLimit must be a finite number."); + expect(() => + $workflow({ + id: "bad-timeout", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commandTimeout: 1.5, + }), + ).toThrowError("$workflow command timeout must be a non-negative integer."); + expect(() => + $workflow({ + id: "bad-migrate", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + migrateSnapshot: "nope", + }), + ).toThrowError("$workflow migrateSnapshot must be a function."); + expect(() => + $workflow({ + id: "bad-state-engine", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + stateEngine: "nope", + }), + ).toThrowError("$workflow stateEngine must be a function."); + expect(() => + $workflow({ + id: "bad-state-engine-result", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + stateEngine() { + return null; + }, + }), + ).toThrowError("$workflow stateEngine must return an engine object."); + expect(() => + $workflow({ + id: "bad-state-engine-current", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + stateEngine() { + return { + current: "", + data: {}, + send() { + return false; + }, + can() { + return false; + }, + matches() { + return false; + }, + snapshot() { + return { + current: "idle", + data: {}, + }; + }, + restore() { + return undefined; + }, + }; + }, + }), + ).toThrowError("$workflow stateEngine must expose a current mode."); + expect(() => + $workflow({ + id: "bad-state-engine-data", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + stateEngine() { + return { + current: "idle", + data: null, + send() { + return false; + }, + can() { + return false; + }, + matches() { + return false; + }, + snapshot() { + return { + current: "idle", + data: {}, + }; + }, + restore() { + return undefined; + }, + }; + }, + }), + ).toThrowError("$workflow stateEngine must expose a data object."); + expect(() => + $workflow({ + id: "bad-state-engine-methods", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + stateEngine() { + return { + current: "idle", + data: {}, + }; + }, + }), + ).toThrowError( + "$workflow stateEngine must expose send, can, matches, snapshot, and restore.", + ); + }); + + it("creates workflow supervisors from existing workflow instances", () => { + const build = $workflow({ + id: "supervisor-build", + initial: "idle", + data: { + status: "idle", + }, + states: { + idle: {}, + }, + }); + const publish = $workflow({ + id: "supervisor-publish", + initial: "idle", + data: { + status: "idle", + }, + states: { + idle: {}, + }, + }); + + const supervisor = createWorkflowSupervisor($workflow, { + id: "pipeline", + workflows: { + build, + publish, + }, + }); + + expect(supervisor.id).toBe("pipeline"); + expect(supervisor.status).toBe("idle"); + expect(supervisor.workflow("build")).toBe(build); + expect(supervisor.workflow("publish")).toBe(publish); + expect(supervisor.snapshot()).toEqual( + jasmine.objectContaining({ + version: 1, + id: "pipeline", + status: "idle", + workflows: jasmine.objectContaining({ + build: jasmine.objectContaining({ + id: "supervisor-build", + }), + publish: jasmine.objectContaining({ + id: "supervisor-publish", + }), + }), + }), + ); + }); + + it("creates workflow supervisors from workflow configs", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "config-pipeline", + workflows: { + build: { + id: "config-build", + initial: "idle", + data: { + count: 0, + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.count += 1; + }, + }, + }, + }, + }, + }, + publish: { + id: "config-publish", + initial: "idle", + data: { + sent: false, + }, + states: { + idle: {}, + }, + }, + }, + }); + const build = supervisor.workflow("build"); + + expect(build.id).toBe("config-build"); + expect(build.current).toBe("idle"); + expect(build.send("start").ok).toBe(true); + expect(build.current).toBe("running"); + expect(build.data.count).toBe(1); + expect(supervisor.workflow("publish").id).toBe("config-publish"); + }); + + it("validates workflow supervisor configuration at creation time", () => { + const workflowConfig = { + id: "supervised", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }; + + expect(() => + createWorkflowSupervisor($workflow, null as ng.WorkflowSupervisorConfig), + ).toThrowError("$workflowSupervisor requires a config object."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "", + workflows: { + build: workflowConfig, + }, + }), + ).toThrowError("$workflowSupervisor requires a non-empty id."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "missing-workflows", + }), + ).toThrowError("$workflowSupervisor requires workflows."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "empty-workflows", + workflows: {}, + }), + ).toThrowError("$workflowSupervisor requires at least one workflow."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "empty-name", + workflows: { + "": workflowConfig, + }, + }), + ).toThrowError( + "$workflowSupervisor workflow names must be non-empty strings.", + ); + expect(() => + createWorkflowSupervisor($workflow, { + id: "duplicate-name", + workflows: [ + ["build", workflowConfig], + [ + "build", + { + ...workflowConfig, + id: "supervised-again", + }, + ], + ], + }), + ).toThrowError("$workflowSupervisor duplicate workflow name 'build'."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "bad-persistence-policy", + persistencePolicy: "after-change", + workflows: { + build: workflowConfig, + }, + }), + ).toThrowError( + "$workflowSupervisor persistencePolicy must be 'manual' or 'after-command'.", + ); + expect(() => + createWorkflowSupervisor($workflow, { + id: "bad-recovery", + recovery: "auto", + workflows: { + build: workflowConfig, + }, + }), + ).toThrowError("$workflowSupervisor recovery must be an object."); + expect(() => + createWorkflowSupervisor($workflow, { + id: "bad-restore-on-start", + recovery: { + restoreOnStart: "yes", + }, + workflows: { + build: workflowConfig, + }, + }), + ).toThrowError( + "$workflowSupervisor recovery.restoreOnStart must be a boolean.", + ); + expect(() => + createWorkflowSupervisor($workflow, { + id: "bad-entry", + workflows: [42], + }), + ).toThrowError( + "$workflowSupervisor workflow entries must be tuples or objects.", + ); + expect(() => + createWorkflowSupervisor($workflow, { + id: "bad-definition", + workflows: { + build: null, + }, + }), + ).toThrowError( + "$workflowSupervisor workflow must be a workflow instance or config object.", + ); + }); + + it("creates workflow supervisors from services and object entries", async () => { + const providerSupervisor = createWorkflowSupervisor($workflow, { + id: "provider-supervisor", + workflows: { + build: { + id: "provider-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + const build = $workflow({ + id: "object-entry-build", + initial: "idle", + data: { + count: 0, + }, + states: { + idle: {}, + }, + }); + const objectEntrySupervisor = createWorkflowSupervisor($workflow, { + id: "object-entry-supervisor", + workflows: [ + { + name: "build", + workflow: build, + }, + { + name: "publish", + config: { + id: "object-entry-publish", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + ], + }); + + const workflowFactory = createWorkflowService((config: MachineConfig) => ({ + ...config, + current: config.initial, + data: config.data, + send() { + return false; + }, + can() { + return false; + }, + matches(mode: string) { + return mode === config.initial; + }, + snapshot() { + return { + current: config.initial, + data: config.data, + }; + }, + restore() { + return undefined; + }, + })); + + expect( + workflowFactory({ + id: "provider-workflow", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }).id, + ).toBe("provider-workflow"); + expect(providerSupervisor.workflow("build").id).toBe("provider-build"); + expect(objectEntrySupervisor.workflow("build")).toBe(build); + expect(objectEntrySupervisor.workflow("publish").id).toBe( + "object-entry-publish", + ); + await expectAsync(providerSupervisor.persist()).toBeResolvedTo( + jasmine.objectContaining({ + id: "provider-supervisor", + }), + ); + await expectAsync(providerSupervisor.load()).toBeResolvedTo(undefined); + expect(() => providerSupervisor.workflow("missing")).toThrowError( + "$workflowSupervisor workflow 'missing' is not registered.", + ); + expect(() => providerSupervisor.workflow(42)).toThrowError( + "$workflowSupervisor workflow '42' is not registered.", + ); + expect(() => providerSupervisor.workflow("")).toThrowError( + "$workflowSupervisor workflow '' is not registered.", + ); + expect(providerSupervisor.diagnostics.slice(-2)).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.missingWorkflow", + workflow: undefined, + }), + jasmine.objectContaining({ + code: "workflowSupervisor.missingWorkflow", + workflow: undefined, + }), + ]); + }); + + it("delegates workflow supervisor run and send calls", async () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "delegation", + workflows: { + build: { + id: "delegated-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.output = String(payload); + }, + }, + }, + }, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }, + }, + }); + + await expectAsync( + supervisor.workflow("build").run("compile", "app.js"), + ).toBeResolvedTo( + jasmine.objectContaining({ + ok: true, + output: { + file: "app.js", + }, + }), + ); + expect(supervisor.workflow("build").send("start", "bundle.js").ok).toBe( + true, + ); + expect(supervisor.workflow("build").current).toBe("running"); + expect(supervisor.workflow("build").data.output).toBe("bundle.js"); + expect(supervisor.workflow("build").send("missing").ok).toBe(false); + }); + + it("delegates workflow supervisor cancellation counts", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const build = $workflow({ + id: "cancel-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: { + async hold() { + await gate; + + return "build"; + }, + }, + }); + const publish = $workflow({ + id: "cancel-publish", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: { + async hold() { + await gate; + + return "publish"; + }, + }, + }); + const supervisor = createWorkflowSupervisor($workflow, { + id: "cancellation", + workflows: { + build, + publish, + }, + }); + const buildRun = supervisor.workflow("build").run("hold"); + const publishRun = supervisor.workflow("publish").run("hold"); + + const buildCancelled = supervisor.workflow("build").cancel("hold"); + const buildResult = await buildRun; + + expect(buildCancelled).toBe(1); + expect(supervisor.workflow("build").cancel("hold")).toBe(0); + expect(supervisor.cancelAll()).toBe(1); + + const publishResult = await publishRun; + + release(); + + expect(buildResult).toEqual( + jasmine.objectContaining({ + ok: false, + }), + ); + expect(publishResult).toEqual( + jasmine.objectContaining({ + ok: false, + }), + ); + }); + + it("snapshots and restores multiple supervised workflows", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "snapshot-pipeline", + workflows: { + build: { + id: "snapshot-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + running: { + on: { + finish: { + to: "done", + update({ data, payload, workflow }) { + data.step = "done"; + }, + }, + }, + }, + }, + }, + publish: { + id: "snapshot-publish", + initial: "idle", + data: { + step: "queued", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "publishing"; + }, + }, + }, + }, + running: { + on: { + finish: { + to: "done", + update({ data, payload, workflow }) { + data.step = "done"; + }, + }, + }, + }, + }, + }, + }, + }); + + supervisor.diagnostics.push({ + code: "custom", + message: "Custom diagnostic.", + detail: { + callback() { + return undefined; + }, + }, + }); + supervisor.workflow("build").send("start"); + supervisor.workflow("publish").send("start"); + + const snapshot = supervisor.snapshot(); + + supervisor.workflow("build").send("finish"); + supervisor.workflow("publish").send("finish"); + + expect(snapshot).toEqual( + jasmine.objectContaining({ + version: 1, + id: "snapshot-pipeline", + status: "idle", + updatedAt: jasmine.any(Number), + workflows: jasmine.objectContaining({ + build: jasmine.objectContaining({ + id: "snapshot-build", + current: "running", + }), + publish: jasmine.objectContaining({ + id: "snapshot-publish", + current: "running", + }), + }), + diagnostics: [ + jasmine.objectContaining({ + code: "custom", + detail: { + callback: "[Function]", + }, + }), + ], + }), + ); + expect(() => JSON.stringify(snapshot.diagnostics)).not.toThrow(); + + supervisor.restore(snapshot); + + expect(supervisor.workflow("build").current).toBe("running"); + expect(supervisor.workflow("build").data.step).toBe("building"); + expect(supervisor.workflow("publish").current).toBe("running"); + expect(supervisor.workflow("publish").data.step).toBe("publishing"); + expect(supervisor.diagnostics).toEqual(snapshot.diagnostics); + }); + + it("rejects malformed workflow supervisor snapshots", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "malformed-snapshot", + workflows: { + build: { + id: "malformed-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + const validSnapshot = supervisor.snapshot(); + + expect(() => supervisor.restore(null)).toThrowError( + "$workflowSupervisor restore requires a snapshot object.", + ); + expect(() => + supervisor.restore({ + ...validSnapshot, + version: 2, + }), + ).toThrowError( + "$workflowSupervisor restore requires a version 1 snapshot.", + ); + expect(() => + supervisor.restore({ + ...validSnapshot, + id: "", + }), + ).toThrowError("$workflowSupervisor restore requires a non-empty id."); + expect(() => + supervisor.restore({ + ...validSnapshot, + status: "paused", + }), + ).toThrowError("$workflowSupervisor restore requires a valid status."); + expect(() => + supervisor.restore({ + ...validSnapshot, + workflows: [], + }), + ).toThrowError("$workflowSupervisor restore requires workflows."); + expect(() => + supervisor.restore({ + ...validSnapshot, + diagnostics: null, + }), + ).toThrowError("$workflowSupervisor restore requires diagnostics."); + expect(() => + supervisor.restore({ + ...validSnapshot, + updatedAt: Number.POSITIVE_INFINITY, + }), + ).toThrowError("$workflowSupervisor restore requires updatedAt."); + expect(() => + supervisor.restore({ + ...validSnapshot, + id: "other-supervisor", + }), + ).toThrowError( + "$workflowSupervisor restore snapshot id must match supervisor id.", + ); + }); + + it("normalizes workflow supervisor diagnostics during restore", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "diagnostic-normalization", + workflows: { + build: { + id: "diagnostic-normalization-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + const snapshot = supervisor.snapshot(); + + supervisor.restore({ + ...snapshot, + diagnostics: [ + "raw diagnostic", + { + code: 42, + message: false, + recoverable: "yes", + workflow: 7, + command: 8, + detail: { + callback() { + return undefined; + }, + }, + }, + { + code: "build.warning", + message: "Build warning.", + recoverable: false, + workflow: "build", + command: "compile", + detail: { + ok: true, + }, + }, + ], + }); + + expect(supervisor.diagnostics).toEqual([ + { + code: "workflowSupervisor.diagnostic", + message: "raw diagnostic", + recoverable: true, + }, + { + code: "workflowSupervisor.diagnostic", + message: "Workflow supervisor diagnostic.", + recoverable: undefined, + workflow: undefined, + command: undefined, + detail: { + callback: "[Function]", + }, + }, + { + code: "build.warning", + message: "Build warning.", + recoverable: false, + workflow: "build", + command: "compile", + detail: { + ok: true, + }, + }, + ]); + }); + + it("records unknown supervisor snapshot workflows and preserves missing workflows", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "partial-snapshot", + workflows: { + build: { + id: "partial-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + }, + }, + publish: { + id: "partial-publish", + initial: "idle", + data: { + step: "queued", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "publishing"; + }, + }, + }, + }, + }, + }, + }, + }); + const snapshot = supervisor.snapshot(); + + supervisor.workflow("build").send("start"); + supervisor.workflow("publish").send("start"); + + const partialSnapshot = { + ...snapshot, + workflows: { + build: snapshot.workflows.build, + ghost: snapshot.workflows.build, + }, + }; + + supervisor.restore(partialSnapshot); + + expect(supervisor.workflow("build").current).toBe("idle"); + expect(supervisor.workflow("build").data.step).toBe("draft"); + expect(supervisor.workflow("publish").current).toBe("running"); + expect(supervisor.workflow("publish").data.step).toBe("publishing"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.unknownSnapshotWorkflow", + workflow: "ghost", + recoverable: true, + }), + ]); + }); + + it("normalizes transient workflow supervisor snapshot statuses on restore", () => { + for (const transientStatus of [ + "running", + "persisting", + "recovering", + ] as const) { + const supervisor = createWorkflowSupervisor($workflow, { + id: `transient-${transientStatus}`, + workflows: { + build: { + id: `transient-${transientStatus}-build`, + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + const snapshot = supervisor.snapshot(); + + supervisor.restore({ + ...snapshot, + status: transientStatus, + }); + + expect(supervisor.status).toBe("idle"); + } + }); + + it("restores failed workflow supervisor snapshots from failed evidence", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "failed-evidence", + workflows: { + build: { + id: "failed-evidence-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + const snapshot = supervisor.snapshot(); + + supervisor.restore({ + ...snapshot, + status: "recovering", + diagnostics: [ + { + code: "workflowSupervisor.recoveryCommandFailed", + message: "Recovery failed.", + recoverable: false, + }, + ], + }); + + expect(supervisor.status).toBe("failed"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.recoveryCommandFailed", + recoverable: false, + }), + ]); + }); + + it("preserves an explicitly failed supervisor snapshot status", () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "failed-status", + workflows: { + build: { + id: "failed-status-build", + initial: "idle", + data: {}, + states: { idle: {} }, + }, + }, + }); + + supervisor.restore({ + ...supervisor.snapshot(), + status: "failed", + }); + + expect(supervisor.status).toBe("failed"); + }); + + it("persists and loads workflow supervisor snapshots through an adapter", async () => { + let savedSnapshot: ng.WorkflowSupervisorSnapshot | undefined; + const persistence: WorkflowSupervisorPersistence = { + async load(id) { + expect(id).toBe("persisted-pipeline"); + + return savedSnapshot; + }, + async save(id, snapshot) { + expect(id).toBe("persisted-pipeline"); + savedSnapshot = structuredClone(snapshot); + }, + }; + const supervisor = createWorkflowSupervisor($workflow, { + id: "persisted-pipeline", + persistence, + workflows: { + build: { + id: "persisted-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + running: { + on: { + finish: { + to: "done", + update({ data, payload, workflow }) { + data.step = "done"; + }, + }, + }, + }, + }, + }, + }, + }); + + supervisor.workflow("build").send("start"); + + const persisted = await supervisor.persist(); + + expect(savedSnapshot).toEqual(persisted); + + supervisor.workflow("build").send("finish"); + + const loaded = await supervisor.load(); + + expect(loaded).toEqual( + jasmine.objectContaining({ + id: "persisted-pipeline", + workflows: jasmine.objectContaining({ + build: jasmine.objectContaining({ + current: "running", + }), + }), + }), + ); + expect(supervisor.workflow("build").current).toBe("running"); + expect(supervisor.workflow("build").data.step).toBe("building"); + }); + + it("handles missing persisted workflow supervisor snapshots", async () => { + const supervisor = createWorkflowSupervisor($workflow, { + id: "missing-persisted-pipeline", + persistence: { + async load() { + return undefined; + }, + async save() { + throw new Error("should not save"); + }, + }, + workflows: { + build: { + id: "missing-persisted-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + }, + }, + }, + }); + + supervisor.workflow("build").send("start"); + + await expectAsync(supervisor.load()).toBeResolvedTo(undefined); + expect(supervisor.workflow("build").current).toBe("running"); + expect(supervisor.workflow("build").data.step).toBe("building"); + expect(supervisor.status).toBe("idle"); + expect(supervisor.diagnostics).toEqual([]); + }); + + it("loads workflow supervisor snapshots on ready when restoreOnStart is enabled", async () => { + let loadCalled = false; + const persistedSnapshot: ng.WorkflowSupervisorSnapshot = { + version: 1, + id: "startup-pipeline", + status: "idle", + workflows: { + build: { + version: 1, + id: "startup-build", + current: "running", + data: { + step: "building", + }, + diagnostics: [], + history: [], + }, + }, + diagnostics: [], + updatedAt: Date.now(), + }; + const supervisor = createWorkflowSupervisor($workflow, { + id: "startup-pipeline", + recovery: { + restoreOnStart: true, + }, + persistence: { + async load(id) { + expect(id).toBe("startup-pipeline"); + loadCalled = true; + + return persistedSnapshot; + }, + async save() { + throw new Error("should not save"); + }, + }, + workflows: { + build: { + id: "startup-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: {}, + running: {}, + }, + }, + }, + }); + + expect(supervisor.status).toBe("recovering"); + expect(supervisor.workflow("build").current).toBe("idle"); + + const readySnapshot = await supervisor.ready; + + expect(loadCalled).toBe(true); + expect(readySnapshot).toEqual( + jasmine.objectContaining({ + id: "startup-pipeline", + workflows: jasmine.objectContaining({ + build: jasmine.objectContaining({ + current: "running", + }), + }), + }), + ); + expect(supervisor.status).toBe("idle"); + expect(supervisor.workflow("build").current).toBe("running"); + expect(supervisor.workflow("build").data.step).toBe("building"); + expect(supervisor.diagnostics).toEqual([]); + }); + + it("records restoreOnStart persistence failures on supervisor ready", async () => { + const loadError = new Error("startup load failed"); + const supervisor = createWorkflowSupervisor($workflow, { + id: "startup-failure", + recovery: { + restoreOnStart: true, + }, + persistence: { + async load() { + throw loadError; + }, + async save() { + throw new Error("should not save"); + }, + }, + workflows: { + build: { + id: "startup-failure-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + + await expectAsync(supervisor.ready).toBeResolvedTo(undefined); + + expect(supervisor.status).toBe("failed"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.persistenceLoadFailed", + recoverable: true, + }), + ]); + }); + + it("records workflow supervisor persistence failures", async () => { + const saveError = new Error("save failed"); + const loadError = new Error("load failed"); + const supervisor = createWorkflowSupervisor($workflow, { + id: "failing-persistence", + persistence: { + async load() { + throw loadError; + }, + async save() { + throw saveError; + }, + }, + workflows: { + build: { + id: "failing-persistence-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }, + }, + }); + + await expectAsync(supervisor.persist()).toBeRejectedWith(saveError); + await expectAsync(supervisor.load()).toBeRejectedWith(loadError); + + expect(supervisor.status).toBe("failed"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.persistenceSaveFailed", + recoverable: true, + }), + jasmine.objectContaining({ + code: "workflowSupervisor.persistenceLoadFailed", + recoverable: true, + }), + ]); + }); + + it("keeps workflow supervisor persistence manual by default", async () => { + let saves = 0; + const supervisor = createWorkflowSupervisor($workflow, { + id: "manual-persistence", + persistence: { + async load() { + return undefined; + }, + async save() { + saves += 1; + }, + }, + workflows: { + build: { + id: "manual-persistence-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }, + }, + }); + + await supervisor.workflow("build").run("compile", "index.html"); + + expect(saves).toBe(0); + }); + + it("persists workflow supervisors after successful commands when configured", async () => { + const saves: ng.WorkflowSupervisorSnapshot[] = []; + const supervisor = createWorkflowSupervisor($workflow, { + id: "after-command-success", + persistencePolicy: "after-command", + persistence: { + async load() { + return undefined; + }, + async save(_id, snapshot) { + saves.push(structuredClone(snapshot)); + }, + }, + workflows: { + build: { + id: "after-command-success-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }, + }, + }); + + expect(supervisor.workflow("build").current).toBe("idle"); + + const result = await supervisor.workflow("build").run("compile", "main.js"); + + expect(result).toEqual( + jasmine.objectContaining({ + ok: true, + output: { + file: "main.js", + }, + }), + ); + expect(saves.length).toBe(1); + expect(saves[0].workflows.build).toEqual( + jasmine.objectContaining({ + data: { + output: "main.js", + }, + }), + ); + }); + + it("persists workflow supervisors after failed command results when configured", async () => { + const saves: ng.WorkflowSupervisorSnapshot[] = []; + const supervisor = createWorkflowSupervisor($workflow, { + id: "after-command-failure", + persistencePolicy: "after-command", + persistence: { + async load() { + return undefined; + }, + async save(_id, snapshot) { + saves.push(structuredClone(snapshot)); + }, + }, + workflows: { + build: { + id: "after-command-failure-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: { + fail() { + return { + ok: false, + diagnostics: [ + { + code: "build.failed", + message: "Build failed.", + }, + ], + }; + }, + }, + }, + }, + }); + + const result = await supervisor.workflow("build").run("fail"); + + expect(result).toEqual( + jasmine.objectContaining({ + ok: false, + }), + ); + expect(saves.length).toBe(1); + expect(saves[0].workflows.build.history).toEqual([ + jasmine.objectContaining({ + type: "command.started", + command: "fail", + }), + jasmine.objectContaining({ + type: "command.failed", + command: "fail", + }), + ]); + }); + + it("does not replace command results when after-command persistence fails", async () => { + const saveError = new Error("autosave failed"); + const supervisor = createWorkflowSupervisor($workflow, { + id: "after-command-save-failure", + persistencePolicy: "after-command", + persistence: { + async load() { + return undefined; + }, + async save() { + throw saveError; + }, + }, + workflows: { + build: { + id: "after-command-save-failure-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }, + }, + }); + + const result = await supervisor.workflow("build").run("compile", "main.js"); + + expect(result).toEqual( + jasmine.objectContaining({ + ok: true, + output: { + file: "main.js", + }, + }), + ); + expect(supervisor.status).toBe("failed"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.persistenceSaveFailed", + recoverable: true, + }), + ]); + }); + + it("retries recoverable workflow supervisor failures when requested", async () => { + let attempts = 0; + const supervisor = createWorkflowSupervisor($workflow, { + id: "recoverable-recovery", + workflows: { + build: { + id: "recoverable-recovery-build", + initial: "idle", + data: { + published: false, + }, + states: { + idle: {}, + }, + commands: { + publish({ data }) { + attempts += 1; + + if (attempts === 1) { + throw new Error("network unavailable"); + } + + data.published = true; + + return { + ok: true, + }; + }, + }, + }, + }, + }); + + const failure = await supervisor.workflow("build").run("publish"); + const recovered = await supervisor.recover(); + + expect(failure.ok).toBe(false); + expect(recovered).toEqual( + jasmine.objectContaining({ + id: "recoverable-recovery", + }), + ); + expect(attempts).toBe(2); + expect(supervisor.workflow("build").data.published).toBe(true); + expect( + supervisor.workflow("build").history.map((entry) => entry.type), + ).toEqual([ + "command.started", + "command.failed", + "command.started", + "command.completed", + ]); + expect(supervisor.status).toBe("idle"); + expect(supervisor.diagnostics).toEqual([]); + }); + + it("recovers multiple workflow supervisor workflows from recoverable failures", async () => { + let buildAttempts = 0; + let publishAttempts = 0; + const supervisor = createWorkflowSupervisor($workflow, { + id: "multi-recovery", + workflows: { + build: { + id: "multi-recovery-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + buildAttempts += 1; + data.output = String(input); + + if (buildAttempts === 1) { + return { + ok: false, + diagnostics: [ + { + code: "build.network", + message: "Build worker unavailable.", + recoverable: true, + }, + ], + }; + } + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }, + publish: { + id: "multi-recovery-publish", + initial: "idle", + data: { + url: "", + }, + states: { + idle: {}, + }, + commands: { + upload({ data, input }) { + publishAttempts += 1; + + if (publishAttempts === 1) { + return { + ok: false, + diagnostics: [ + { + code: "publish.network", + message: "Upload endpoint unavailable.", + recoverable: true, + }, + ], + }; + } + + data.url = `/docs/${String(input)}`; + + return { + ok: true, + output: { + url: data.url, + }, + }; + }, + }, + }, + }, + }); + + await supervisor.workflow("build").run("compile", "index.html"); + await supervisor.workflow("publish").run("upload", "index.html"); + + const recovered = await supervisor.recover(); + + expect(recovered).toEqual( + jasmine.objectContaining({ + id: "multi-recovery", + }), + ); + expect(buildAttempts).toBe(2); + expect(publishAttempts).toBe(2); + expect(supervisor.workflow("build").data.output).toBe("index.html"); + expect(supervisor.workflow("publish").data.url).toBe("/docs/index.html"); + expect( + supervisor.workflow("build").history.map((entry) => entry.type), + ).toEqual([ + "command.started", + "command.failed", + "command.started", + "command.completed", + ]); + expect( + supervisor.workflow("publish").history.map((entry) => entry.type), + ).toEqual([ + "command.started", + "command.failed", + "command.started", + "command.completed", + ]); + expect(supervisor.status).toBe("idle"); + expect(supervisor.diagnostics).toEqual([]); + }); + + it("does not retry non-recoverable workflow supervisor failures", async () => { + let attempts = 0; + const supervisor = createWorkflowSupervisor($workflow, { + id: "non-recoverable-recovery", + workflows: { + build: { + id: "non-recoverable-recovery-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: { + publish() { + attempts += 1; + + return { + ok: false, + diagnostics: [ + { + code: "publish.auth", + message: "Authentication failed.", + recoverable: false, + }, + ], + }; + }, + }, + }, + }, + }); + + const failure = await supervisor.workflow("build").run("publish"); + const recovered = await supervisor.recover(); + + expect(failure.ok).toBe(false); + expect(recovered).toBeUndefined(); + expect(attempts).toBe(1); + expect( + supervisor.workflow("build").history.map((entry) => entry.type), + ).toEqual(["command.started", "command.failed"]); + expect(supervisor.status).toBe("idle"); + expect(supervisor.diagnostics).toEqual([]); + }); + + it("records workflow supervisor recovery diagnostics with workflow and command names", async () => { + let attempts = 0; + const supervisor = createWorkflowSupervisor($workflow, { + id: "failed-recovery", + workflows: { + build: { + id: "failed-recovery-build", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + commands: { + publish() { + attempts += 1; + + return { + ok: false, + diagnostics: [ + { + code: "publish.network", + message: "Network unavailable.", + recoverable: true, + }, + ], + }; + }, + }, + }, + }, + }); + + await supervisor.workflow("build").run("publish"); + + const recovered = await supervisor.recover(); + + expect(recovered).toEqual( + jasmine.objectContaining({ + id: "failed-recovery", + }), + ); + expect(attempts).toBe(2); + expect(supervisor.status).toBe("failed"); + expect(supervisor.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflowSupervisor.recoveryCommandFailed", + workflow: "build", + command: "publish", + recoverable: true, + }), + ]); + }); + + it("saves, loads, and removes workflow supervisor snapshots through IndexedDB", async () => { + const database = `angular-ts-workflow-supervisor-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + const persistence = indexedDbWorkflowSupervisorPersistence({ + database, + store: "snapshots", + }); + + try { + const supervisor = createWorkflowSupervisor($workflow, { + id: "indexed-pipeline", + persistence, + workflows: { + build: { + id: "indexed-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + }, + }, + }, + }); + + supervisor.workflow("build").send("start"); + + await supervisor.persist(); + + const restored = createWorkflowSupervisor($workflow, { + id: "indexed-pipeline", + persistence, + workflows: { + build: { + id: "indexed-build", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "building"; + }, + }, + }, + }, + }, + }, + }, + }); + const loaded = await restored.load(); + + expect(loaded).toEqual( + jasmine.objectContaining({ + id: "indexed-pipeline", + }), + ); + expect(restored.workflow("build").current).toBe("running"); + expect(restored.workflow("build").data.step).toBe("building"); + + await persistence.remove?.("indexed-pipeline"); + + await expectAsync(persistence.load("indexed-pipeline")).toBeResolvedTo( + undefined, + ); + } finally { + await deleteIndexedDbDatabase(database); + } + }); + + it("validates IndexedDB workflow supervisor persistence configuration", async () => { + expect(() => + indexedDbWorkflowSupervisorPersistence({ + database: "", + }), + ).toThrowError("$workflowSupervisor IndexedDB database must be non-empty."); + expect(() => + indexedDbWorkflowSupervisorPersistence({ + store: "", + }), + ).toThrowError("$workflowSupervisor IndexedDB store must be non-empty."); + expect(() => + indexedDbWorkflowSupervisorPersistence({ + version: 0, + }), + ).toThrowError( + "$workflowSupervisor IndexedDB version must be a positive integer.", + ); + + const originalIndexedDb = globalThis.indexedDB; + + Object.defineProperty(globalThis, "indexedDB", { + configurable: true, + value: undefined, + }); + + try { + const persistence = indexedDbWorkflowSupervisorPersistence(); + + await expectAsync(persistence.load("missing")).toBeRejectedWithError( + "$workflowSupervisor IndexedDB persistence requires indexedDB.", + ); + } finally { + Object.defineProperty(globalThis, "indexedDB", { + configurable: true, + value: originalIndexedDb, + }); + } + }); + + it("surfaces IndexedDB workflow supervisor open, request, and transaction failures", async () => { + const createIndexedDb = ({ + blockOpen = false, + openError, + openFails = false, + requestError, + requestFails = false, + transactionAbort = false, + transactionError, + transactionFails = false, + }: { + blockOpen?: boolean; + openError?: Error; + openFails?: boolean; + requestError?: Error; + requestFails?: boolean; + transactionAbort?: boolean; + transactionError?: Error; + transactionFails?: boolean; + }): IDBFactory => + ({ + open() { + const transaction = { + error: transactionError, + objectStore() { + return { + get() { + const request = { + error: requestError, + result: undefined, + } as IDBRequest; + + setTimeout(() => { + if (requestFails) { + request.onerror?.({} as Event); + + return; + } + + request.onsuccess?.({} as Event); + + setTimeout(() => { + if (transactionFails) { + transaction.onerror?.({} as Event); + } else if (transactionAbort) { + transaction.onabort?.({} as Event); + } else { + transaction.oncomplete?.({} as Event); + } + }); + }); + + return request; + }, + }; + }, + } as IDBTransaction; + const database = { + close: jasmine.createSpy("close"), + createObjectStore: jasmine.createSpy("createObjectStore"), + objectStoreNames: { + contains: jasmine.createSpy("contains").and.returnValue(true), + }, + transaction: jasmine + .createSpy("transaction") + .and.returnValue(transaction), + } as unknown as IDBDatabase; + const request = { + error: openError, + result: database, + } as IDBOpenDBRequest; + + setTimeout(() => { + if (blockOpen) { + request.onblocked?.({} as Event); + } else if (openFails) { + request.onerror?.({} as Event); + } else { + request.onsuccess?.({} as Event); + } + }); + + return request; + }, + }) as IDBFactory; + + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + openError: new Error("open failed"), + openFails: true, + }), + version: 2, + }).load("open-error"), + ).toBeRejectedWithError("open failed"); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + openFails: true, + }), + version: 2, + }).load("open-fallback"), + ).toBeRejectedWithError("IndexedDB open failed."); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + blockOpen: true, + }), + version: 2, + }).load("open-blocked"), + ).toBeRejectedWithError("IndexedDB open was blocked."); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + requestFails: true, + requestError: new Error("request failed"), + }), + version: 2, + }).load("request-error"), + ).toBeRejectedWithError("request failed"); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + requestFails: true, + }), + version: 2, + }).load("request-fallback"), + ).toBeRejectedWithError("IndexedDB request failed."); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + transactionError: new Error("transaction failed"), + transactionFails: true, + }), + version: 2, + }).load("transaction-error"), + ).toBeRejectedWithError("transaction failed"); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + transactionFails: true, + }), + version: 2, + }).load("transaction-fallback"), + ).toBeRejectedWithError("IndexedDB transaction failed."); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + transactionAbort: true, + transactionError: new Error("transaction aborted"), + }), + version: 2, + }).load("transaction-abort-error"), + ).toBeRejectedWithError("transaction aborted"); + await expectAsync( + indexedDbWorkflowSupervisorPersistence({ + indexedDB: createIndexedDb({ + transactionAbort: true, + }), + version: 2, + }).load("transaction-abort"), + ).toBeRejectedWithError("IndexedDB transaction aborted."); + }); + + it("runs workflow commands through the workflow worker adapter", async () => { + const snapshots: Array>> = []; + const workflow = $workflow({ + id: "worker-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + output: { + file: data.output, + }, + }; + }, + }, + }); + const host = createWorkflowWorkerHost({ + workflows: { + build: workflow, + }, + }); + const client = createWorkflowWorkerClient( + createWorkflowWorkerTestConnection(host), + ); + + client.onSnapshot((snapshot) => snapshots.push(snapshot)); + + const result = await client.run("build", "compile", "index.html"); + + expect(result).toEqual( + jasmine.objectContaining({ + ok: true, + output: { + file: "index.html", + }, + }), + ); + expect(snapshots.length).toBe(1); + expect(client.latestSnapshot?.build.data).toEqual({ + output: "index.html", + }); + expect(workflow.history.map((entry) => entry.type)).toEqual([ + "command.started", + "command.completed", + ]); + + client.dispose(); + }); + + it("sends workflow events through the workflow worker adapter", async () => { + const workflow = $workflow({ + id: "worker-events", + initial: "idle", + data: { + status: "idle", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.status = String(payload); + }, + }, + }, + }, + }, + }); + const client = createWorkflowWorkerClient( + createWorkflowWorkerTestConnection( + createWorkflowWorkerHost({ + workflows: { + build: workflow, + }, + }), + ), + ); + + await expectAsync( + client.send("build", "start", "building").then((result) => result.ok), + ).toBeResolvedTo(true); + expect(workflow.current).toBe("running"); + expect(workflow.data.status).toBe("building"); + expect(client.latestSnapshot?.build.current).toBe("running"); + + client.dispose(); + }); + + it("restores worker-hosted workflows from supervisor snapshots after restart", async () => { + const firstWorkflow = $workflow({ + id: "worker-restart-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + commands: { + compile({ data, input }) { + data.output = String(input); + + return { + ok: true, + }; + }, + }, + }); + const firstClient = createWorkflowWorkerClient( + createWorkflowWorkerTestConnection( + createWorkflowWorkerHost({ + workflows: { + build: firstWorkflow, + }, + }), + ), + ); + + await firstClient.run("build", "compile", "restart.html"); + + const workerSnapshot = await firstClient.snapshot(); + const supervisorSnapshot: ng.WorkflowSupervisorSnapshot = { + version: 1, + id: "worker-supervisor", + status: "idle", + workflows: workerSnapshot, + diagnostics: [], + updatedAt: Date.now(), + }; + const restartedWorkflow = $workflow({ + id: "worker-restart-build", + initial: "idle", + data: { + output: "", + }, + states: { + idle: {}, + }, + }); + const restartedClient = createWorkflowWorkerClient( + createWorkflowWorkerTestConnection( + createWorkflowWorkerHost({ + workflows: { + build: restartedWorkflow, + }, + }), + ), + ); + + const restored = await restartedClient.restore(supervisorSnapshot); + + expect(restored.build.data).toEqual({ + output: "restart.html", + }); + expect(restartedWorkflow.data.output).toBe("restart.html"); + expect(restartedClient.latestSnapshot?.build.data).toEqual({ + output: "restart.html", + }); + + firstClient.dispose(); + restartedClient.dispose(); + }); + + it("validates workflow worker host and client contracts", async () => { + const workflow = $workflow({ + id: "worker-contracts", + initial: "idle", + data: {}, + states: { + idle: {}, + }, + }); + + expect(() => + createWorkflowWorkerHost(null as WorkflowWorkerHostConfig), + ).toThrowError("Workflow worker host requires a config object."); + expect(() => + createWorkflowWorkerHost({ + workflows: null, + } as WorkflowWorkerHostConfig), + ).toThrowError("Workflow worker host requires workflows."); + expect(() => + createWorkflowWorkerHost({ + workflows: { + "": workflow, + }, + }), + ).toThrowError("Workflow worker host workflow names must be non-empty."); + expect(() => + createWorkflowWorkerHost({ + workflows: { + build: {}, + }, + } as WorkflowWorkerHostConfig), + ).toThrowError( + "Workflow worker host workflows must be workflow instances.", + ); + expect(() => + createWorkflowWorkerClient(null as ng.WorkerConnection), + ).toThrowError("Workflow worker client requires a WorkerConnection."); + + const host = createWorkflowWorkerHost({ + workflows: { + build: workflow, + }, + }); + const post = jasmine.createSpy("post"); + + await host.handle(null, post); + + expect(post).not.toHaveBeenCalled(); + }); + + it("handles workflow worker failure, disposal, and snapshot edges", async () => { + const workflow = $workflow({ + id: "worker-edge-build", + initial: "idle", + data: { + status: "idle", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.status = "running"; + }, + }, + }, + }, + }, + }); + const quietConnection = createWorkflowWorkerTestConnection( + createWorkflowWorkerHost({ + publishSnapshots: false, + workflows: { + build: workflow, + }, + }), + ); + const quietClient = createWorkflowWorkerClient(quietConnection); + const snapshots: Array>> = []; + const unsubscribe = quietClient.onSnapshot((snapshot) => { + snapshots.push(snapshot); + }); + + await expectAsync( + quietClient.send("build", "start").then((result) => result.ok), + ).toBeResolvedTo(true); + expect(snapshots).toEqual([]); + expect(quietClient.latestSnapshot).toBeUndefined(); + + quietConnection.dispatch({ + type: "angular-ts:workflow-worker:snapshot", + snapshot: { + build: workflow.snapshot(), + }, + }); + unsubscribe(); + quietConnection.dispatch({ + type: "angular-ts:workflow-worker:snapshot", + snapshot: { + build: workflow.snapshot(), + }, + }); + + expect(snapshots.length).toBe(1); + + const previousMessage = jasmine.createSpy("previousMessage"); + const failingListeners = new Set< + (data: unknown, event: MessageEvent) => void + >(); + const dispatchFailingMessage = (data: unknown) => { + const event = { data } as MessageEvent; + + for (const listener of failingListeners) { + listener(data, event); + } + + return event; + }; + const failingConnection: ng.WorkerConnection = { + postMessage(data) { + const request = data as WorkflowWorkerRequest; + + dispatchFailingMessage({ + type: "angular-ts:workflow-worker:response", + id: request.id, + ok: false, + }); + }, + onMessage(listener) { + failingListeners.add(listener); + + return () => failingListeners.delete(listener); + }, + restart() { + return undefined; + }, + terminate() { + return undefined; + }, + }; + failingConnection.onMessage(previousMessage); + const failingClient = createWorkflowWorkerClient(failingConnection); + + dispatchFailingMessage({ + type: "angular-ts:workflow-worker:response", + id: "unknown", + ok: true, + }); + const passthroughEvent = dispatchFailingMessage("passthrough"); + + await expectAsync(failingClient.snapshot()).toBeRejectedWithError( + "Workflow worker request failed.", + ); + expect(previousMessage).toHaveBeenCalledWith( + "passthrough", + passthroughEvent, + ); + + const hostClient = createWorkflowWorkerClient( + createWorkflowWorkerTestConnection( + createWorkflowWorkerHost({ + workflows: { + build: workflow, + }, + }), + ), + ); + + await expectAsync(hostClient.run("missing", "compile")).toBeResolvedTo( + jasmine.objectContaining({ + ok: false, + diagnostics: [ + jasmine.objectContaining({ + code: "workflowWorker.missingWorkflow", + }), + ], + }), + ); + await expectAsync(hostClient.run("", "compile")).toBeResolvedTo( + jasmine.objectContaining({ + ok: false, + diagnostics: [ + jasmine.objectContaining({ + code: "workflowWorker.missingWorkflow", + message: "Workflow worker host requires a workflow name.", + }), + ], + }), + ); + await expectAsync(hostClient.run("build", "")).toBeResolvedTo( + jasmine.objectContaining({ + ok: false, + diagnostics: [ + jasmine.objectContaining({ + code: "workflowWorker.invalidCommand", + }), + ], + }), + ); + await expectAsync( + hostClient.send("missing", "start").then((result) => result.ok), + ).toBeResolvedTo(false); + await expectAsync( + hostClient.send("build", "").then((result) => result.ok), + ).toBeResolvedTo(false); + await expectAsync( + hostClient + .send("build", null as unknown as string) + .then((result) => result.type), + ).toBeResolvedTo(""); + await expectAsync( + hostClient.restore({ + build: workflow.snapshot(), + ghost: workflow.snapshot(), + }), + ).toBeResolvedTo( + jasmine.objectContaining({ + build: jasmine.objectContaining({ + id: "worker-edge-build", + }), + }), + ); + await expectAsync(hostClient.restore(null)).toBeRejectedWithError( + "Workflow worker restore requires a snapshot object.", + ); - return "running"; - }, - }, + const pendingConnection: ng.WorkerConnection = { + postMessage() { + return undefined; }, - }); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("idle"); - expect(element.querySelector(".status")?.textContent).toBe("idle"); - - $rootScope.build.send("start"); - - await wait(); - - expect(element.querySelector(".mode")?.textContent).toBe("running"); - expect(element.querySelector(".status")?.textContent).toBe("running"); - }); - - it("returns typed workflow and command definitions unchanged", () => { - const command = defineCommand(({ input }) => ({ - ok: true, - output: String(input), - })); - const config = { - id: "defined", - initial: "idle", - data: { - value: "", + onMessage() { + return () => undefined; }, - transitions: {}, - commands: { - publish: command, + restart() { + return undefined; + }, + terminate() { + return undefined; }, }; + const pendingClient = createWorkflowWorkerClient(pendingConnection); + const pendingSnapshot = pendingClient.snapshot(); - expect(defineCommand(command)).toBe(command); - expect(defineWorkflow(config)).toBe(config); - }); + pendingClient.dispose(); - it("validates workflow configuration at creation time", () => { - expect(() => $workflow(null as ng.WorkflowConfig)).toThrowError( - "$workflow requires a config object.", + await expectAsync(pendingSnapshot).toBeRejectedWithError( + "Workflow worker client is disposed.", ); - expect(() => - $workflow({ - id: "", - initial: "idle", - data: {}, - transitions: {}, - }), - ).toThrowError("$workflow requires a non-empty id."); - expect(() => - $workflow({ - id: "bad-initial", - initial: "", - data: {}, - transitions: {}, - }), - ).toThrowError("$workflow requires a non-empty initial mode."); - expect(() => - $workflow({ - id: "bad-data", - initial: "idle", - data: null, - transitions: {}, - }), - ).toThrowError("$workflow requires a data object."); - expect(() => - $workflow({ - id: "bad-transitions", - initial: "idle", - data: {}, - transitions: null, - }), - ).toThrowError("$workflow requires a transitions object."); - expect(() => - $workflow({ - id: "bad-commands", - initial: "idle", - data: {}, - transitions: {}, - commands: null, - }), - ).toThrowError("$workflow commands must be an object."); - expect(() => - $workflow({ - id: "bad-concurrency", - initial: "idle", - data: {}, - transitions: {}, - concurrency: "drop", - }), - ).toThrowError( - "$workflow concurrency must be 'allow', 'reject', or 'queue'.", + await expectAsync(pendingClient.snapshot()).toBeRejectedWithError( + "Workflow worker client is disposed.", ); - expect(() => - $workflow({ - id: "bad-history-limit", - initial: "idle", - data: {}, - transitions: {}, - historyLimit: -1, - }), - ).toThrowError("$workflow historyLimit must be a non-negative integer."); - expect(() => - $workflow({ - id: "bad-diagnostic-limit", - initial: "idle", - data: {}, - transitions: {}, - diagnosticLimit: Number.POSITIVE_INFINITY, - }), - ).toThrowError("$workflow diagnosticLimit must be a finite number."); - expect(() => - $workflow({ - id: "bad-timeout", - initial: "idle", - data: {}, - transitions: {}, - commandTimeout: 1.5, - }), - ).toThrowError("$workflow command timeout must be a non-negative integer."); - expect(() => - $workflow({ - id: "bad-migrate", - initial: "idle", - data: {}, - transitions: {}, - migrateSnapshot: "nope", - }), - ).toThrowError("$workflow migrateSnapshot must be a function."); + + quietClient.dispose(); + failingClient.dispose(); + hostClient.dispose(); }); it("runs commands through stable history and diagnostics boundaries", async () => { @@ -179,7 +2836,9 @@ describe("$workflow", () => { data: { output: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { build({ data, input }) { data.output = String(input); @@ -234,7 +2893,9 @@ describe("$workflow", () => { data: { value: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { async resolve({ data, input }) { await Promise.resolve(); @@ -264,6 +2925,7 @@ describe("$workflow", () => { expect(success).toEqual({ ok: true, + status: "completed", output: { value: "ready", }, @@ -302,7 +2964,9 @@ describe("$workflow", () => { data: { value: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { async slow({ data }) { await new Promise((resolve) => { @@ -372,7 +3036,9 @@ describe("$workflow", () => { concurrency: "reject", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { async build() { await new Promise((resolve) => { @@ -391,6 +3057,7 @@ describe("$workflow", () => { expect(second).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandRunning", @@ -419,7 +3086,9 @@ describe("$workflow", () => { data: { values: [] as string[], }, - transitions: {}, + states: { + idle: {}, + }, commands: { async build({ data, input }) { started.push(String(input)); @@ -462,7 +3131,9 @@ describe("$workflow", () => { id: "cancel", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { async build({ cleanup, signal: commandSignal }) { signal = commandSignal; @@ -489,6 +3160,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: false, + status: "cancelled", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandCancelled", @@ -507,7 +3179,9 @@ describe("$workflow", () => { id: "pre-aborted", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { build() { ran = true; @@ -528,6 +3202,7 @@ describe("$workflow", () => { expect(ran).toBe(false); expect(result).toEqual({ ok: false, + status: "cancelled", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandCancelled", @@ -542,7 +3217,9 @@ describe("$workflow", () => { id: "cleanup-failure", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { build({ cleanup }) { cleanup(() => { @@ -585,12 +3262,15 @@ describe("$workflow", () => { count: 0, }, }, - transitions: { + states: { idle: { - start(data) { - data.value = "running"; - - return "running"; + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.value = "running"; + }, + }, }, }, }, @@ -624,9 +3304,9 @@ describe("$workflow", () => { Object.setPrototypeOf(capturedData!, { changed: true }); const sent = capturedWorkflow!.send("start"); + const invalidSent = capturedWorkflow!.send(null as unknown as string); const nestedRun = await capturedWorkflow!.run("nested"); const nestedRetry = await capturedWorkflow!.retry("nested"); - const nestedRepeat = await capturedWorkflow!.repeat("nested"); expect(workflow.current).toBe("idle"); expect(workflow.data).toEqual({ @@ -635,10 +3315,10 @@ describe("$workflow", () => { count: 0, }, }); - expect(sent).toBe(false); + expect(sent.ok).toBe(false); + expect(invalidSent.type).toBe(""); expect(nestedRun.diagnostics[0].code).toBe("workflow.commandCancelled"); expect(nestedRetry.diagnostics[0].code).toBe("workflow.commandCancelled"); - expect(nestedRepeat.diagnostics[0].code).toBe("workflow.commandCancelled"); }); it("proxies Map and Set workflow data during commands", async () => { @@ -653,7 +3333,9 @@ describe("$workflow", () => { map: new Map([["item", { count: 1 }]]), set: new Set(["one"]), }, - transitions: {}, + states: { + idle: {}, + }, commands: { inspect({ data }) { capturedData = data; @@ -678,6 +3360,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: true, + status: "completed", output: "ok", }); expect(workflow.data.map.get("item")!.count).toBe(2); @@ -705,7 +3388,9 @@ describe("$workflow", () => { data: { items: ["one"], }, - transitions: {}, + states: { + idle: {}, + }, commands: { append({ data }) { capturedItems = data.items; @@ -720,6 +3405,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: true, + status: "completed", output: "one,two", }); expect(workflow.data.items).toEqual(["one", "two"]); @@ -747,7 +3433,9 @@ describe("$workflow", () => { data: { counter: new Counter(1), }, - transitions: {}, + states: { + idle: {}, + }, commands: { increment({ data }) { capturedCounter = data.counter; @@ -761,6 +3449,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: true, + status: "completed", output: 2, }); expect(capturedCounter).toBeInstanceOf(Counter); @@ -773,7 +3462,9 @@ describe("$workflow", () => { commandTimeout: 1, initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { async build() { await new Promise(() => undefined); @@ -789,6 +3480,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: false, + status: "timeout", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandTimeout", @@ -809,7 +3501,9 @@ describe("$workflow", () => { data: { runs: 0, }, - transitions: {}, + states: { + idle: {}, + }, commands: { build({ data }) { data.runs += 1; @@ -823,7 +3517,7 @@ describe("$workflow", () => { const invalidConcurrency = await workflow.run("build", undefined, { concurrency: "drop", - } as ng.WorkflowCommandOptions); + } as WorkflowCommandOptions); const invalidTimeout = await workflow.run("build", undefined, { timeout: -1, }); @@ -833,6 +3527,7 @@ describe("$workflow", () => { expect(invalidConcurrency).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.invalidCommandOptions", @@ -876,7 +3571,9 @@ describe("$workflow", () => { id: "abort-listener-cleanup", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { build() { return { @@ -904,7 +3601,9 @@ describe("$workflow", () => { items: new Map([["current", "initial"]]), value: "initial", }, - transitions: {}, + states: { + idle: {}, + }, commands: { async build({ data, input }) { started.push(String(input)); @@ -945,6 +3644,7 @@ describe("$workflow", () => { expect(firstResult).toEqual({ ok: false, + status: "cancelled", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandCancelled", @@ -954,6 +3654,7 @@ describe("$workflow", () => { }); expect(secondResult).toEqual({ ok: false, + status: "cancelled", diagnostics: [ jasmine.objectContaining({ code: "workflow.commandCancelled", @@ -976,7 +3677,9 @@ describe("$workflow", () => { historyLimit: 3, initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { ping({ input }) { return { @@ -1004,12 +3707,62 @@ describe("$workflow", () => { expect(workflow.history[0].output).toBe("two"); }); + it("normalizes bare and explicitly failed command results", async () => { + const workflow = $workflow({ + id: "failure-results", + initial: "idle", + data: {}, + states: { idle: {} }, + commands: { + bare() { + return { ok: false }; + }, + explicit() { + return { + ok: false, + status: "failed", + diagnostics: [ + { + code: "build.failed", + message: "Build failed.", + }, + ], + }; + }, + }, + }); + + const bare = await workflow.run("bare"); + const explicit = await workflow.run("explicit"); + + expect(bare).toEqual({ + ok: false, + status: "failed", + diagnostics: [ + jasmine.objectContaining({ + code: "workflow.commandFailed", + }), + ], + }); + expect(explicit).toEqual({ + ok: false, + status: "failed", + diagnostics: [ + jasmine.objectContaining({ + code: "build.failed", + }), + ], + }); + }); + it("returns stable diagnostics for invalid or missing commands", async () => { const workflow = $workflow({ id: "diagnostics", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, }); const invalid = await workflow.run(""); @@ -1017,6 +3770,7 @@ describe("$workflow", () => { expect(invalid).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.invalidCommand", @@ -1026,6 +3780,7 @@ describe("$workflow", () => { }); expect(missing).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.missingCommand", @@ -1042,13 +3797,15 @@ describe("$workflow", () => { ]); }); - it("does not treat empty replay or cancel command names as wildcards", async () => { + it("does not treat empty retry or cancel command names as wildcards", async () => { let resolveBuild: (() => void) | undefined; const workflow = $workflow({ id: "empty-command-boundaries", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { async build() { await new Promise((resolve) => { @@ -1064,26 +3821,17 @@ describe("$workflow", () => { const running = workflow.run("build"); const retry = await workflow.retry(""); - const repeat = await workflow.repeat(""); expect(workflow.cancel("")).toBe(0); expect(retry).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.invalidCommand", }), ], }); - expect(repeat).toEqual({ - ok: false, - diagnostics: [ - jasmine.objectContaining({ - code: "workflow.invalidCommand", - }), - ], - }); - resolveBuild?.(); expect((await running).ok).toBe(true); @@ -1094,28 +3842,202 @@ describe("$workflow", () => { id: "transition-diagnostics", initial: "idle", data: {}, - transitions: { + states: { running: { - stop() { - return "idle"; + on: { + stop: { + to: "idle", + }, + }, + }, + }, + }); + + expect(workflow.send("stop").ok).toBe(false); + expect(workflow.current).toBe("idle"); + expect(workflow.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflow.invalidTransition", + recoverable: true, + detail: jasmine.objectContaining({ + current: "idle", + status: "missing-transition", + type: "stop", + payload: undefined, + }), + }), + ]); + }); + + it("reports state-tree guard denial from the default machine engine", () => { + const workflow = $workflow({ + id: "state-transition-diagnostics", + initial: "idle", + data: { + allowed: false, + }, + states: { + idle: { + on: { + start: { + to: "running", + guard({ data }) { + return data.allowed; + }, + }, + }, + }, + running: {}, + }, + }); + + expect(workflow.send("start").ok).toBe(false); + expect(workflow.current).toBe("idle"); + expect(workflow.diagnostics).toEqual([ + jasmine.objectContaining({ + code: "workflow.invalidTransition", + recoverable: true, + detail: jasmine.objectContaining({ + current: "idle", + status: "guard-denied", + type: "start", + payload: undefined, + }), + }), + ]); + }); + + it("runs commands through a custom workflow state engine", async () => { + const events: string[] = []; + let current = "idle"; + const engineData = { + count: 0, + output: "", + }; + const workflow = $workflow({ + id: "custom-engine", + initial: "idle", + data: engineData, + states: { + idle: {}, + }, + stateEngine() { + return { + get current() { + return current; + }, + get data() { + return engineData; + }, + send(type, payload) { + const from = current; + events.push(type); + + if (current === "idle" && type === "start") { + current = "running"; + engineData.count += 1; + + return { + ok: true, + status: "transitioned", + type, + from, + to: current, + data: engineData, + payload, + }; + } + + if (current === "running" && type === "complete") { + current = "complete"; + engineData.output = payload.output; + + return { + ok: true, + status: "transitioned", + type, + from, + to: current, + data: engineData, + payload, + }; + } + + return { + ok: false, + status: "missing-transition", + type, + from, + to: current, + data: engineData, + payload, + }; + }, + can(type) { + return ( + (current === "idle" && type === "start") || + (current === "running" && type === "complete") + ); }, + matches(mode) { + return current === mode; + }, + snapshot() { + return { + current, + data: structuredClone(engineData), + }; + }, + restore(snapshot) { + current = snapshot.current; + engineData.count = snapshot.data.count; + engineData.output = snapshot.data.output; + }, + }; + }, + commands: { + build({ data, input, workflow: currentWorkflow }) { + data.output = String(input); + currentWorkflow.send("start"); + currentWorkflow.send("complete", { output: data.output }); + + return { + ok: true, + output: { + file: data.output, + }, + }; }, }, }); - expect(workflow.send("stop")).toBe(false); - expect(workflow.current).toBe("idle"); - expect(workflow.diagnostics).toEqual([ - jasmine.objectContaining({ - code: "workflow.invalidTransition", - recoverable: true, - detail: { - current: "idle", - type: "stop", - payload: undefined, - }, - }), + expect(workflow.can("start")).toBe(true); + + const result = await workflow.run("build", "index.html"); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.output).toEqual({ + file: "index.html", + }); + } + expect(events).toEqual(["start", "complete"]); + expect(workflow.matches("complete")).toBe(true); + expect(workflow.data).toBe(engineData); + expect(workflow.data.count).toBe(1); + expect(workflow.history.map((entry) => entry.type)).toEqual([ + "command.started", + "command.completed", ]); + + const snapshot = workflow.snapshot(); + + engineData.output = "changed.html"; + workflow.restore(snapshot); + + expect(workflow.current).toBe("complete"); + expect(workflow.data.output).toBe("index.html"); }); it("bounds diagnostics with a configurable limit", async () => { @@ -1124,7 +4046,7 @@ describe("$workflow", () => { diagnosticLimit: 2, initial: "idle", data: {}, - transitions: { + states: { idle: {}, }, commands: { @@ -1161,7 +4083,9 @@ describe("$workflow", () => { file: "", published: false, }, - transitions: {}, + states: { + idle: {}, + }, commands: { publish({ data, input }) { attempts += 1; @@ -1189,6 +4113,7 @@ describe("$workflow", () => { expect(failure.ok).toBe(false); expect(retry).toEqual({ ok: true, + status: "completed", output: { file: "index.html", }, @@ -1213,7 +4138,9 @@ describe("$workflow", () => { data: { values: [] as string[], }, - transitions: {}, + states: { + idle: {}, + }, commands: { publish({ data, input }) { attempts += 1; @@ -1276,13 +4203,16 @@ describe("$workflow", () => { id: "retry-empty", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, }); const result = await workflow.retry("publish"); expect(result).toEqual({ ok: false, + status: "rejected", diagnostics: [ jasmine.objectContaining({ code: "workflow.noFailedCommand", @@ -1294,14 +4224,16 @@ describe("$workflow", () => { expect(workflow.history).toEqual([]); }); - it("repeats the latest completed command with the original input", async () => { + it("runs the same command again with explicit input", async () => { const workflow = $workflow({ id: "repeat", initial: "idle", data: { files: [] as string[], }, - transitions: {}, + states: { + idle: {}, + }, commands: { publish({ data, input }) { data.files.push(String(input)); @@ -1317,11 +4249,12 @@ describe("$workflow", () => { }); const first = await workflow.run("publish", "index.html"); - const repeated = await workflow.repeat(); + const repeated = await workflow.run("publish", "index.html"); expect(first.ok).toBe(true); expect(repeated).toEqual({ ok: true, + status: "completed", output: { file: "index.html", }, @@ -1337,29 +4270,6 @@ describe("$workflow", () => { expect(workflow.history[2].input).toBe("index.html"); }); - it("returns a recoverable diagnostic when there is no completed command to repeat", async () => { - const workflow = $workflow({ - id: "repeat-empty", - initial: "idle", - data: {}, - transitions: {}, - }); - - const result = await workflow.repeat("publish"); - - expect(result).toEqual({ - ok: false, - diagnostics: [ - jasmine.objectContaining({ - code: "workflow.noCompletedCommand", - command: "publish", - recoverable: true, - }), - ], - }); - expect(workflow.history).toEqual([]); - }); - it("uses configured repair commands without deleting failure evidence", async () => { const workflow = $workflow({ id: "repair", @@ -1368,18 +4278,27 @@ describe("$workflow", () => { title: "", repaired: false, }, - transitions: { + states: { idle: { - validate(data) { - return data.title ? "complete" : "failed"; + on: { + validate: { + update(context) { + const nextMode = context.data.title ? "complete" : "failed"; + if (typeof nextMode === "string" && nextMode) { + context.to = nextMode; + } + }, + }, }, }, failed: { - reset() { - return "idle"; - }, - complete() { - return "complete"; + on: { + reset: { + to: "idle", + }, + complete: { + to: "complete", + }, }, }, }, @@ -1418,7 +4337,7 @@ describe("$workflow", () => { const repair = await workflow.run("repair", "Guide"); expect(failure.ok).toBe(false); - expect(reset).toBe(true); + expect(reset.ok).toBe(true); expect(secondFailure.ok).toBe(false); expect(repair.ok).toBe(true); expect(workflow.current).toBe("complete"); @@ -1455,7 +4374,9 @@ describe("$workflow", () => { id: "serializable", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { inspect() { return { @@ -1489,12 +4410,15 @@ describe("$workflow", () => { data: { count: 0, }, - transitions: { + states: { idle: { - start(data) { - data.count += 1; - - return "running"; + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.count += 1; + }, + }, }, }, }, @@ -1543,15 +4467,19 @@ describe("$workflow", () => { data: { title: "", }, - transitions: { + states: { idle: { - fail() { - return "failed"; + on: { + fail: { + to: "failed", + }, }, }, failed: { - complete() { - return "complete"; + on: { + complete: { + to: "complete", + }, }, }, }, @@ -1612,7 +4540,9 @@ describe("$workflow", () => { id: "restore-target", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, }); expect(() => @@ -1645,7 +4575,9 @@ describe("$workflow", () => { data: { title: "", }, - transitions: {}, + states: { + idle: {}, + }, migrateSnapshot(snapshot) { const oldSnapshot = snapshot as { state: string; @@ -1688,7 +4620,9 @@ describe("$workflow", () => { data: { value: "", }, - transitions: {}, + states: { + idle: {}, + }, commands: { publish({ data, input }) { data.value = String(input); @@ -1751,7 +4685,7 @@ describe("$workflow", () => { ], }); - const repeat = await workflow.repeat("publish"); + const repeated = await workflow.run("publish", "restored.html"); expect(workflow.diagnostics).toEqual([ jasmine.objectContaining({ @@ -1807,7 +4741,7 @@ describe("$workflow", () => { command: "fractional", }), ); - expect(repeat.ok).toBe(true); + expect(repeated.ok).toBe(true); expect(workflow.data.value).toBe("restored.html"); expect(workflow.history[4].id).toBe(7); expect(workflow.history[5].id).toBe(8); @@ -1820,12 +4754,15 @@ describe("$workflow", () => { data: { runs: 0, }, - transitions: { + states: { idle: { - start(data) { - data.runs += 1; - - return "running"; + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.runs += 1; + }, + }, }, }, }, @@ -1836,11 +4773,60 @@ describe("$workflow", () => { const second = injector.get("docsWorkflow"); expect(first).toBe(second); - expect(first.send("start")).toBe(true); + expect(first.send("start").ok).toBe(true); expect(second.current).toBe("running"); expect(second.data.runs).toBe(1); }); + it("registers named workflow supervisors as singleton injectables", async () => { + window.angular + .module("workflowSupervisorNamedApp", ["ng"]) + .workflowSupervisor("docsSupervisor", { + id: "docs-supervisor", + workflows: { + build: { + id: "docs-supervisor-build", + initial: "idle", + data: { + runs: 0, + }, + states: { + idle: {}, + }, + commands: { + build({ data, input }) { + data.runs += 1; + + return { + ok: true, + output: { + file: String(input), + }, + }; + }, + }, + }, + }, + }); + + const injector = createInjector(["workflowSupervisorNamedApp"]); + const first = injector.get("docsSupervisor") as ng.WorkflowSupervisor; + const second = injector.get("docsSupervisor") as ng.WorkflowSupervisor; + + const result = await first.workflow("build").run("build", "index.html"); + + expect(first).toBe(second); + expect(result).toEqual( + jasmine.objectContaining({ + ok: true, + output: { + file: "index.html", + }, + }), + ); + expect(second.workflow("build").data.runs).toBe(1); + }); + it("keeps named workflows alive after one observing directive scope is destroyed", async () => { const directiveScopes: ng.Scope[] = []; @@ -1853,12 +4839,15 @@ describe("$workflow", () => { data: { status: "idle", }, - transitions: { + states: { setup: { - wait(data) { - data.status = "waiting"; - - return "waiting"; + on: { + wait: { + to: "waiting", + update({ data, payload, workflow }) { + data.status = "waiting"; + }, + }, }, }, }, @@ -1879,7 +4868,7 @@ describe("$workflow", () => { const workflow = injector.get("sessionWorkflow") as ng.Workflow<{ status: string; }>; - const rootScope = injector.get("$rootScope") as ng.RootScopeService; + const rootScope = injector.get("$rootScope") as ng.Scope; rootScope.session = workflow; @@ -1907,6 +4896,278 @@ describe("$workflow", () => { ); }); + it("replaces workflow progress and recovery UI as bounded fragments", async () => { + const workflow = $workflow({ + id: "publish", + initial: "idle", + data: { + step: "draft", + }, + states: { + idle: { + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.step = "uploading"; + }, + }, + }, + }, + running: { + on: { + fail: { + to: "failed", + update({ data, payload, workflow }) { + data.step = "recover"; + }, + }, + }, + }, + failed: { + on: { + retry: { + to: "running", + update({ data, payload, workflow }) { + data.step = "uploading"; + }, + }, + }, + }, + }, + }); + const shell = document.createElement("section"); + const stable = document.createElement("strong"); + const target = document.createElement("div"); + + stable.className = "stable"; + stable.textContent = "Shell"; + shell.append(stable, target); + $rootScope.workflow = workflow; + + const host = createWorkflowUiFragmentHost({ + compile: $compile, + scope: $rootScope, + target, + }); + const progress = host.render( + '

{{ workflow.current }}:{{ workflow.data.step }}

', + ); + const progressNode = progress.nodes[0]; + + await wait(); + + expect(target.querySelector(".progress")?.textContent).toBe("idle:draft"); + expect(shell.querySelector(".stable")).toBe(stable); + + workflow.send("start"); + await wait(); + + expect(target.querySelector(".progress")?.textContent).toBe( + "running:uploading", + ); + + const snapshot = workflow.snapshot(); + + workflow.send("fail"); + const recovery = host.render( + '', + ); + + await wait(); + + expect(progress.disposed).toBe(true); + expect(getCompiledFragmentRecord(progressNode)).toBeUndefined(); + expect(target.querySelector(".recovery")?.textContent).toBe("0:recover"); + + workflow.restore(snapshot); + const resumed = host.render( + '

{{ workflow.current }}:{{ workflow.data.step }}

', + ); + + await wait(); + + expect(recovery.disposed).toBe(true); + expect(host.current).toBe(resumed); + expect(target.querySelector(".resumed")?.textContent).toBe( + "running:uploading", + ); + expect(shell.querySelector(".stable")).toBe(stable); + }); + + it("disposes workflow UI fragments without cancelling workflow commands", async () => { + let complete!: () => void; + const commandGate = new Promise((resolve) => { + complete = resolve; + }); + const workflow = $workflow({ + id: "approval", + initial: "waiting", + data: { + approved: false, + }, + states: { + waiting: { + on: { + approve: { + to: "approved", + update({ data, payload, workflow }) { + data.approved = true; + }, + }, + }, + }, + }, + commands: { + async approve({ workflow: currentWorkflow }) { + await commandGate; + currentWorkflow.send("approve"); + + return "approved"; + }, + }, + }); + const target = document.createElement("div"); + + $rootScope.workflow = workflow; + + const host = createWorkflowUiFragmentHost({ + compile: $compile, + scope: $rootScope, + target, + }); + const fragment = host.render( + '

{{ workflow.current }}

', + ); + const command = workflow.run("approve"); + + host.dispose(); + + expect(fragment.disposed).toBe(true); + expect(target.childNodes.length).toBe(0); + + complete(); + + const result = await command; + + expect(result.ok).toBe(true); + expect(workflow.current).toBe("approved"); + expect(workflow.data.approved).toBe(true); + }); + + it("ignores late workflow UI fragment DOM work after binding disposal", () => { + const workflow = $workflow({ + id: "diagnostics", + initial: "idle", + data: { + value: "ready", + }, + states: { + idle: {}, + }, + }); + const target = document.createElement("div"); + + $rootScope.workflow = workflow; + + const host = createWorkflowUiFragmentHost({ + compile: $compile, + scope: $rootScope, + target, + }); + const fragment = host.render( + '

{{ workflow.data.value }}

', + ); + let rendered = false; + + host.dispose(); + + const scheduled = scheduleCompiledFragmentDomWork(fragment, () => { + rendered = true; + }); + + expect(scheduled).toBe(false); + expect(rendered).toBe(false); + expect(workflow.data.value).toBe("ready"); + }); + + it("owns workflow UI fragments through scope destruction", () => { + const scope = $rootScope.$new(); + const target = document.createElement("div"); + const host = createWorkflowUiFragmentHost({ + compile: $compile, + scope, + target, + }); + const fragment = host.render('

owned

'); + + scope.$destroy(); + + expect(fragment.disposed).toBe(true); + expect(host.current).toBeNull(); + expect(target.childNodes.length).toBe(0); + + host.dispose(); + expect(() => host.render("

late

")).toThrowError( + "Cannot render a disposed workflow UI fragment host.", + ); + }); + + it("accepts a compiler that returns one linked fragment node", () => { + const target = document.createElement("div"); + const singleNodeCompile = (template) => { + const link = $compile(template); + + return (scope) => { + const linked = link(scope); + + return Array.isArray(linked) ? linked[0] : linked; + }; + }; + const host = createWorkflowUiFragmentHost({ + compile: singleNodeCompile, + scope: $rootScope, + target, + }); + const fragment = host.render('

single

'); + + expect(fragment.nodes.length).toBe(1); + expect(target.querySelector(".single")?.textContent).toBe("single"); + + const arrayTarget = document.createElement("div"); + const arrayCompile = (template) => { + const link = $compile(template); + + return (scope) => { + const linked = link(scope); + + return Array.isArray(linked) ? linked : [linked]; + }; + }; + const arrayHost = createWorkflowUiFragmentHost({ + compile: arrayCompile, + scope: $rootScope, + target: arrayTarget, + }); + const arrayFragment = arrayHost.render('

array

'); + + expect(arrayFragment.nodes.length).toBe(1); + expect(arrayTarget.querySelector(".array")?.textContent).toBe("array"); + }); + + it("rejects compiler output without fragment ownership", () => { + const target = document.createElement("div"); + const host = createWorkflowUiFragmentHost({ + compile: () => () => document.createElement("p"), + scope: $rootScope, + target, + }); + + expect(() => host.render("ignored")).toThrowError( + "Workflow UI fragment host requires a compiled fragment.", + ); + }); + describe("documentation examples", () => { it("runs the create workflow example", async () => { const build = $workflow({ @@ -1916,25 +5177,32 @@ describe("$workflow", () => { status: "idle", output: "", }, - transitions: { + states: { idle: { - start(data) { - data.status = "running"; - - return "running"; + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.status = "running"; + }, + }, }, }, running: { - complete(data, output) { - data.status = "complete"; - data.output = output; - - return "complete"; - }, - fail(data, reason) { - data.status = reason; - - return "failed"; + on: { + complete: { + to: "complete", + update({ data, payload: output, workflow }) { + data.status = "complete"; + data.output = output; + }, + }, + fail: { + to: "failed", + update({ data, payload: reason, workflow }) { + data.status = reason; + }, + }, }, }, }, @@ -1958,6 +5226,7 @@ describe("$workflow", () => { expect(result).toEqual({ ok: true, + status: "completed", output: { file: "index.html", }, @@ -1979,12 +5248,15 @@ describe("$workflow", () => { data: { runs: 0, }, - transitions: { + states: { idle: { - start(data) { - data.runs += 1; - - return "running"; + on: { + start: { + to: "running", + update({ data, payload, workflow }) { + data.runs += 1; + }, + }, }, }, }, @@ -1993,7 +5265,7 @@ describe("$workflow", () => { const injector = createInjector(["workflowDocsNamedApp"]); const docsWorkflow = injector.get("docsWorkflow"); - expect(docsWorkflow.send("start")).toBe(true); + expect(docsWorkflow.send("start").ok).toBe(true); expect(docsWorkflow.current).toBe("running"); expect(docsWorkflow.data.runs).toBe(1); }); @@ -2003,7 +5275,9 @@ describe("$workflow", () => { id: "diagnostics-example", initial: "idle", data: {}, - transitions: {}, + states: { + idle: {}, + }, commands: { publish() { return { @@ -2042,10 +5316,12 @@ describe("$workflow", () => { data: { title: "", }, - transitions: { + states: { idle: { - fail() { - return "failed"; + on: { + fail: { + to: "failed", + }, }, }, }, @@ -2087,7 +5363,7 @@ describe("$workflow", () => { } }); - it("runs the retry and repeat examples", async () => { + it("runs the retry and explicit rerun examples", async () => { let attempts = 0; const workflow = $workflow({ id: "retry-repeat", @@ -2095,7 +5371,9 @@ describe("$workflow", () => { data: { files: [] as string[], }, - transitions: {}, + states: { + idle: {}, + }, commands: { publish({ data, input }) { attempts += 1; @@ -2119,10 +5397,10 @@ describe("$workflow", () => { await workflow.run("publish", "index.html"); const retryResult = await workflow.retry("publish"); - const repeatResult = await workflow.repeat("publish"); + const repeatedResult = await workflow.run("publish", "index.html"); expect(retryResult.ok).toBe(true); - expect(repeatResult.ok).toBe(true); + expect(repeatedResult.ok).toBe(true); expect(workflow.data.files).toEqual(["index.html", "index.html"]); }); @@ -2133,15 +5411,24 @@ describe("$workflow", () => { data: { title: "", }, - transitions: { + states: { idle: { - validate(data) { - return data.title ? "complete" : "failed"; + on: { + validate: { + update(context) { + const nextMode = context.data.title ? "complete" : "failed"; + if (typeof nextMode === "string" && nextMode) { + context.to = nextMode; + } + }, + }, }, }, failed: { - complete() { - return "complete"; + on: { + complete: { + to: "complete", + }, }, }, }, diff --git a/src/services/workflow/workflow.ts b/src/services/workflow/workflow.ts index 1ba1a9733..e03cc800c 100644 --- a/src/services/workflow/workflow.ts +++ b/src/services/workflow/workflow.ts @@ -1,10 +1,8 @@ import { SCOPE_PROXY_BIND, - createScope, type Scope, type ScopeProxyBindable, } from "../../core/scope/scope.ts"; -import { _machine } from "../../injection-tokens.ts"; import { hasOwn, isArray, @@ -16,39 +14,106 @@ import { isString, } from "../../shared/utils.ts"; import type { + Machine, MachineConfig, MachineMode, - MachineNoEvents, + MachineSendResult, MachineService, - MachineTransitionMap, + MachineStateMap, } from "../machine/machine.ts"; +import type { WorkerConnection } from "../worker/worker.ts"; -export type WorkflowMode = MachineMode; - -export type WorkflowStatus = WorkflowMode; +export type WorkflowNoEvents = Record; export type WorkflowNoCommands = Record; export type WorkflowConcurrencyPolicy = "allow" | "reject" | "queue"; export interface WorkflowDiagnostic { - code: string; - message: string; - recoverable?: boolean; - path?: string; - command?: string; - detail?: unknown; + readonly code: string; + readonly message: string; + readonly recoverable?: boolean; + readonly path?: string; + readonly command?: string; + readonly detail?: unknown; } export type WorkflowCommandResult = | { - ok: true; - output?: TOutput; - diagnostics?: WorkflowDiagnostic[]; + readonly ok: true; + readonly status: "completed"; + readonly output?: TOutput; + readonly diagnostics?: readonly WorkflowDiagnostic[]; + } + | { + readonly ok: false; + readonly status: Exclude; + readonly diagnostics: readonly WorkflowDiagnostic[]; + }; + +export type WorkflowCommandStatus = + | "completed" + | "failed" + | "cancelled" + | "timeout" + | "rejected"; + +type WorkflowCommandReturn = + | { + readonly ok: true; + readonly status?: "completed"; + readonly output?: TOutput; + readonly diagnostics?: readonly WorkflowDiagnostic[]; + } + | { + readonly ok: false; + readonly status?: Exclude; + readonly diagnostics: readonly WorkflowDiagnostic[]; + }; + +type WorkflowEventName = Extract; + +/** @inline */ +type WorkflowEventPayload< + TEvents extends object, + TType extends WorkflowEventName, +> = undefined extends TEvents[TType] + ? [payload?: TEvents[TType]] + : [payload: TEvents[TType]]; + +export interface WorkflowStateEngineSnapshot< + TData extends object = Record, +> { + readonly current: MachineMode; + readonly data: TData; +} + +export type WorkflowSendResult> = + | { + readonly ok: true; + readonly status: "transitioned" | "updated"; + readonly type: string; + readonly from?: MachineMode; + readonly to?: MachineMode; + readonly data?: TData; + readonly payload?: unknown; + readonly detail?: unknown; } | { - ok: false; - diagnostics: WorkflowDiagnostic[]; + readonly ok: false; + readonly status: + | "missing-transition" + | "missing-workflow" + | "guard-denied" + | "policy-denied" + | "invalid-event" + | "command-finished"; + readonly type: string; + readonly from?: MachineMode; + readonly to?: MachineMode; + readonly data?: TData; + readonly payload?: unknown; + readonly detail?: unknown; }; export interface WorkflowCommandOptions { @@ -58,41 +123,92 @@ export interface WorkflowCommandOptions { } export interface WorkflowCommandContext< - TData extends object = Record, TInput = unknown, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, - TName extends string = string, + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, > { - workflow: Workflow; + workflow: Workflow>; data: TData; input: TInput; - command: TName; + command: string; cleanup(callback: () => void): void; signal: AbortSignal; } +type WorkflowCommandHandlerResult = + | WorkflowCommandReturn + | TOutput + | Promise | TOutput>; + export type WorkflowCommand< - TData extends object = Record, TInput = unknown, TOutput = unknown, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, - TName extends string = string, + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, > = ( - context: WorkflowCommandContext, -) => - | WorkflowCommandResult - | TOutput - | Promise | TOutput>; + context: WorkflowCommandContext, +) => WorkflowCommandHandlerResult; export type WorkflowCommandMap< TData extends object = Record, - TEvents extends object = MachineNoEvents, -> = Record>; + TEvents extends object = WorkflowNoEvents, +> = Record>; + +/** + * Advanced extension API for custom workflow state engines. + * + * Ordinary workflow authors should configure `states`; implement this contract + * only when packaging an alternate state-engine adapter. + * + * @see docs/content/docs/service/workflow.md#advanced-extension-apis + */ +export interface WorkflowStateEngine< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, +> { + readonly current: MachineMode; + readonly data: TData; + send>( + type: TType, + ...payload: WorkflowEventPayload + ): WorkflowSendResult; + can>( + type: TType, + ...payload: WorkflowEventPayload + ): boolean; + matches(mode: MachineMode): boolean; + snapshot(): WorkflowStateEngineSnapshot; + restore(snapshot: WorkflowStateEngineSnapshot): void; +} + +export interface WorkflowStateEngineContext< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, +> { + config: WorkflowConfig; + createDefaultEngine(): WorkflowStateEngine; +} + +/** + * Advanced extension API for creating custom workflow state engines. + * + * @see docs/content/docs/service/workflow.md#advanced-extension-apis + */ +export type WorkflowStateEngineFactory< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, +> = ( + context: WorkflowStateEngineContext, +) => WorkflowStateEngine; type WorkflowCommandDefs = { - [TName in keyof TCommands]: (context: never) => unknown; + [TName in keyof TCommands]: TCommands[TName] extends ( + ...args: never[] + ) => unknown + ? TCommands[TName] + : never; }; type WorkflowCommandName = Extract< @@ -102,16 +218,15 @@ type WorkflowCommandName = Extract< type WorkflowCommandParts = TCommand extends WorkflowCommand< - infer TData, infer TInput, infer TOutput, - infer TEvents, - infer TCommands, - infer TName + infer TData, + infer TEvents > - ? [TData, TInput, TOutput, TEvents, TCommands, TName] - : [never, unknown, unknown, never, never, string]; + ? [TData, TInput, TOutput, TEvents] + : [never, unknown, unknown, never]; +/** @inline */ type WorkflowCommandInput< TCommands extends object, TName extends WorkflowCommandName, @@ -120,12 +235,26 @@ type WorkflowCommandInput< type WorkflowCommandOutput< TCommands extends object, TName extends WorkflowCommandName, -> = WorkflowCommandParts[2]; +> = WorkflowCommandReturnOutput< + ReturnType unknown>> +>; + +/** @inline */ +type WorkflowCommandReturnOutput = + Awaited extends infer TResolved + ? TResolved extends { ok: true; output?: infer TOutput } + ? TOutput + : TResolved extends { ok: false } + ? never + : TResolved + : never; +/** @inline */ type WorkflowCommandInputArgs = undefined extends TInput ? [input?: TInput, options?: WorkflowCommandOptions] : [input: TInput, options?: WorkflowCommandOptions]; +/** @inline */ type WorkflowCommandOutputUnion = WorkflowCommandName extends infer TName ? TName extends WorkflowCommandName @@ -133,57 +262,85 @@ type WorkflowCommandOutputUnion = : never : never; -type WorkflowRun = string extends keyof TCommands - ? ( - command: string, - input?: unknown, - options?: WorkflowCommandOptions, - ) => Promise> - : >( - command: TName, - ...input: WorkflowCommandInputArgs> - ) => Promise< - WorkflowCommandResult> - >; - -type WorkflowReplay = string extends keyof TCommands - ? ( - command?: string, - options?: WorkflowCommandOptions, - ) => Promise> - : { - ( - options?: WorkflowCommandOptions, - ): Promise>>; - >( - command: TName, - options?: WorkflowCommandOptions, - ): Promise< - WorkflowCommandResult> - >; - }; +export type WorkflowSupervisorWorkflowMap = Record; + +export type WorkflowFromDefinition = + TDefinition extends Workflow + ? Workflow + : TDefinition extends WorkflowConfig< + infer TData, + infer TEvents, + infer TCommands + > + ? Workflow + : Workflow; + +type WorkflowSupervisorWorkflowName = Extract< + keyof TWorkflows, + string +>; + +export type WorkflowSupervisorSnapshotMap = { + [TWorkflowName in keyof TWorkflows & + string]: TWorkflows[TWorkflowName] extends Workflow< + infer TData, + infer TEvents, + infer TCommands + > + ? TEvents extends object + ? TCommands extends object + ? WorkflowSnapshot + : never + : never + : TWorkflows[TWorkflowName] extends WorkflowConfig< + infer TData, + infer TConfigEvents, + infer TConfigCommands + > + ? TConfigEvents extends object + ? TConfigCommands extends object + ? WorkflowSnapshot + : never + : never + : WorkflowSnapshot; +}; export interface WorkflowHistoryEntry { - id: number; - type: "command.started" | "command.completed" | "command.failed"; - command: string; - input?: unknown; - output?: unknown; - diagnostics?: WorkflowDiagnostic[]; + readonly id: number; + readonly type: "command.started" | "command.completed" | "command.failed"; + readonly command: string; + readonly input?: unknown; + readonly output?: unknown; + readonly diagnostics?: readonly WorkflowDiagnostic[]; } -interface WorkflowBaseConfig { +interface WorkflowCommonConfig< + TData extends object, + TEvents extends object, + TCommands extends object, +> { commandTimeout?: number; concurrency?: WorkflowConcurrencyPolicy; diagnosticLimit?: number; id: string; - initial: WorkflowMode; + initial: MachineMode; data: TData; historyLimit?: number; migrateSnapshot?: WorkflowSnapshotMigration; - transitions: MachineTransitionMap; + stateEngine?: WorkflowStateEngineFactory; +} + +interface WorkflowStateConfig { + states: MachineStateMap; } +type WorkflowBaseConfig< + TData extends object, + TEvents extends object, + TCommands extends object, +> = WorkflowCommonConfig & + WorkflowStateConfig; + type WorkflowCommandConfig = keyof TCommands extends never ? { @@ -193,158 +350,1763 @@ type WorkflowCommandConfig = commands: TCommands & WorkflowCommandDefs; }; -export type WorkflowConfig< - TData extends object = Record, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, -> = WorkflowBaseConfig & WorkflowCommandConfig; +export type WorkflowConfig< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, +> = WorkflowBaseConfig & + WorkflowCommandConfig; + +type WorkflowInferredDefinition = Omit< + WorkflowCommonConfig, + "data" | "stateEngine" +> & { + data: object; + states: object; + commands?: object; + stateEngine?: WorkflowStateEngineFactory; +}; + +type WorkflowDataFromDefinition = TDefinition extends { + data: infer TData extends object; +} + ? TData + : Record; + +type WorkflowCommandMapFromDefinition = TDefinition extends { + commands: infer TCommands extends object; +} + ? TCommands + : WorkflowNoCommands; + +type WorkflowEventNamesFromState = TState extends { + on: infer TTransitions extends object; +} + ? Extract + : never; + +type WorkflowEventNamesFromStates = TStates extends object + ? { + [TState in keyof TStates]: WorkflowEventNamesFromState; + }[keyof TStates] + : never; + +type WorkflowEventsFromDefinition = TDefinition extends { + states: infer TStates; +} + ? { + [TEvent in WorkflowEventNamesFromStates]: unknown; + } + : WorkflowNoEvents; + +export interface WorkflowSnapshot< + TData extends object = Record, +> { + readonly version: 1; + readonly id: string; + readonly current: MachineMode; + readonly data: TData; + readonly diagnostics: readonly WorkflowDiagnostic[]; + readonly history: readonly WorkflowHistoryEntry[]; +} + +export type WorkflowSnapshotMigration< + TData extends object = Record, +> = (snapshot: unknown) => WorkflowSnapshot; + +export interface Workflow< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, +> { + readonly id: string; + readonly current: MachineMode; + data: TData; + readonly diagnostics: readonly WorkflowDiagnostic[]; + readonly history: readonly WorkflowHistoryEntry[]; + send>( + type: TType, + ...payload: WorkflowEventPayload + ): WorkflowSendResult; + can>( + type: TType, + ...payload: WorkflowEventPayload + ): boolean; + matches(mode: MachineMode): boolean; + run>( + command: TName, + ...input: WorkflowCommandInputArgs> + ): Promise>>; + retry( + options?: WorkflowCommandOptions, + ): Promise>>; + retry>( + command: TName, + options?: WorkflowCommandOptions, + ): Promise>>; + cancel(command?: string): number; + snapshot(): WorkflowSnapshot; + restore(snapshot: unknown): void; +} + +export interface WorkflowService { + < + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, + >( + config: WorkflowConfig, + ): Workflow; +} + +export type WorkflowSupervisorStatus = + | "idle" + | "running" + | "persisting" + | "recovering" + | "failed"; + +export interface WorkflowSupervisorDiagnostic { + readonly code: string; + readonly message: string; + readonly recoverable?: boolean; + readonly workflow?: string; + readonly command?: string; + readonly detail?: unknown; +} + +export interface WorkflowSupervisorSnapshot< + TWorkflowSnapshots extends Record> = Record< + string, + WorkflowSnapshot + >, +> { + readonly version: 1; + readonly id: string; + readonly status: WorkflowSupervisorStatus; + readonly workflows: TWorkflowSnapshots; + readonly diagnostics: readonly WorkflowSupervisorDiagnostic[]; + readonly updatedAt: number; +} + +export interface WorkflowSupervisorPersistence< + TSnapshot extends WorkflowSupervisorSnapshot = WorkflowSupervisorSnapshot, +> { + load(id: string): Promise; + save(id: string, snapshot: TSnapshot): Promise; + remove?(id: string): Promise; +} + +export type WorkflowSupervisorPersistencePolicy = "manual" | "after-command"; + +export interface WorkflowSupervisorIndexedDbPersistenceConfig { + database?: string; + store?: string; + version?: number; + indexedDB?: IDBFactory; +} + +export interface WorkflowSupervisorRecoveryPolicy { + restoreOnStart?: boolean; +} + +export interface WorkflowSupervisorConfig< + TWorkflows extends WorkflowSupervisorWorkflowMap = + WorkflowSupervisorWorkflowMap, +> { + id: string; + workflows: TWorkflows; + persistence?: WorkflowSupervisorPersistence< + WorkflowSupervisorSnapshot> + >; + persistencePolicy?: WorkflowSupervisorPersistencePolicy; + recovery?: WorkflowSupervisorRecoveryPolicy; +} + +export interface WorkflowSupervisor< + TWorkflows extends WorkflowSupervisorWorkflowMap = + WorkflowSupervisorWorkflowMap, +> { + readonly id: string; + readonly status: WorkflowSupervisorStatus; + readonly diagnostics: readonly WorkflowSupervisorDiagnostic[]; + readonly ready: Promise< + | WorkflowSupervisorSnapshot> + | undefined + >; + workflow>( + name: TWorkflowName, + ): WorkflowFromDefinition; + cancelAll(): number; + snapshot(): WorkflowSupervisorSnapshot< + WorkflowSupervisorSnapshotMap + >; + restore(snapshot: unknown): void; + persist(): Promise< + WorkflowSupervisorSnapshot> + >; + load(): Promise< + | WorkflowSupervisorSnapshot> + | undefined + >; + recover(): Promise< + | WorkflowSupervisorSnapshot> + | undefined + >; +} + +export type WorkflowWorkerRequestOperation = + | "run" + | "send" + | "snapshot" + | "restore"; + +export interface WorkflowWorkerRequest { + type: "angular-ts:workflow-worker:request"; + id: string; + operation: WorkflowWorkerRequestOperation; + workflow?: string; + command?: string; + event?: string; + input?: unknown; + payload?: unknown; + snapshot?: unknown; +} + +export interface WorkflowWorkerResponse { + type: "angular-ts:workflow-worker:response"; + id: string; + ok: boolean; + result?: TResult; + error?: WorkflowDiagnostic; +} + +export interface WorkflowWorkerSnapshotMessage { + type: "angular-ts:workflow-worker:snapshot"; + snapshot: Record>; +} + +export type WorkflowWorkerMessage = + | WorkflowWorkerRequest + | WorkflowWorkerResponse + | WorkflowWorkerSnapshotMessage; + +/** + * Advanced extension API for hosting workflow instances behind a Worker-like + * message transport. + * + * @see docs/content/docs/service/workflow.md#advanced-extension-apis + */ +export interface WorkflowWorkerHost { + handle( + message: unknown, + post: (message: WorkflowWorkerMessage) => void, + ): Promise; + snapshot(): Record>; +} + +export interface WorkflowWorkerHostConfig< + TWorkflows extends Record = Record, +> { + workflows: TWorkflows; + publishSnapshots?: boolean; +} + +/** + * Advanced extension API for calling worker-hosted workflows from the page. + * + * @see docs/content/docs/service/workflow.md#advanced-extension-apis + */ +export interface WorkflowWorkerClient { + readonly latestSnapshot: Record> | undefined; + run( + workflow: string, + command: string, + input?: unknown, + ): Promise>; + send( + workflow: string, + event: string, + payload?: unknown, + ): Promise; + snapshot(): Promise>>; + restore(snapshot: unknown): Promise>>; + onSnapshot( + callback: (snapshot: Record>) => void, + ): () => void; + dispose(): void; +} + +type WorkflowTarget< + TData extends object, + TEvents extends object, + TCommands extends object, +> = Workflow & ScopeProxyBindable; + +interface WorkflowBinding< + TData extends object, + TEvents extends object, + TCommands extends object, +> { + _handler: Scope; + _proxy: Workflow; +} + +interface WorkflowRunState { + _cancel: (diagnostic: WorkflowDiagnostic) => void; + _cancelDiagnostic?: WorkflowDiagnostic; + _cancelPromise: Promise; + _cleanups: Array<() => void>; + _command: string; + _controller: AbortController; + _discardResult: boolean; + _done: boolean; +} + +/** @internal */ +export function createWorkflowService( + $machine: MachineService, +): WorkflowService { + return createWorkflowFactory($machine) as WorkflowService; +} + +export function defineWorkflow< + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, + TCommands extends object = WorkflowNoCommands, +>( + config: WorkflowConfig, +): WorkflowConfig; +export function defineWorkflow< + const TDefinition extends WorkflowInferredDefinition, +>( + config: TDefinition, +): WorkflowConfig< + WorkflowDataFromDefinition, + WorkflowEventsFromDefinition, + WorkflowCommandMapFromDefinition +> & + TDefinition; +export function defineWorkflow( + config: WorkflowConfig | WorkflowInferredDefinition, +): WorkflowConfig | WorkflowInferredDefinition { + return config; +} + +export function defineCommand< + TInput = unknown, + TOutput = unknown, + TData extends object = Record, + TEvents extends object = WorkflowNoEvents, +>( + command: WorkflowCommand, +): WorkflowCommand { + return command; +} + +export function createWorkflowSupervisor< + TWorkflows extends WorkflowSupervisorWorkflowMap, +>( + $workflow: WorkflowService, + config: WorkflowSupervisorConfig, +): WorkflowSupervisor { + const workflows = createWorkflowSupervisorRegistry($workflow, config); + const exposedWorkflows = new Map(); + const diagnostics: WorkflowSupervisorDiagnostic[] = []; + let status: WorkflowSupervisorStatus = "idle"; + + function snapshot(): WorkflowSupervisorSnapshot< + WorkflowSupervisorSnapshotMap + > { + const workflowSnapshots: Record> = {}; + + for (const [name, workflow] of workflows) { + workflowSnapshots[name] = workflow.snapshot() as WorkflowSnapshot; + } + + return { + version: 1, + id: config.id, + status, + workflows: workflowSnapshots as WorkflowSupervisorSnapshotMap, + diagnostics: normalizeWorkflowSupervisorDiagnostics(diagnostics), + updatedAt: Date.now(), + }; + } + + function appendSupervisorDiagnostic( + diagnostic: WorkflowSupervisorDiagnostic, + ): WorkflowSupervisorDiagnostic { + diagnostics.push(diagnostic); + + return diagnostic; + } + + function createMissingWorkflowDiagnostic( + workflowName: unknown, + command?: string, + ): WorkflowSupervisorDiagnostic { + const formattedWorkflow = isString(workflowName) ? workflowName : ""; + const workflow = + isString(workflowName) && workflowName ? workflowName : undefined; + + return { + code: "workflowSupervisor.missingWorkflow", + message: formattedWorkflow + ? `Workflow supervisor '${config.id}' does not have workflow '${formattedWorkflow}'.` + : `Workflow supervisor '${config.id}' requires a workflow name.`, + recoverable: true, + workflow, + command, + detail: normalizeDiagnosticDetail({ + supervisor: config.id, + workflow: workflowName, + }), + }; + } + + function createUnknownSnapshotWorkflowDiagnostic( + workflowName: string, + ): WorkflowSupervisorDiagnostic { + return { + code: "workflowSupervisor.unknownSnapshotWorkflow", + message: `Workflow supervisor '${config.id}' snapshot includes unknown workflow '${workflowName}'.`, + recoverable: true, + workflow: workflowName, + detail: normalizeDiagnosticDetail({ + supervisor: config.id, + workflow: workflowName, + }), + }; + } + + function createPersistenceDiagnostic( + code: string, + action: "load" | "save", + error: unknown, + ): WorkflowSupervisorDiagnostic { + return { + code, + message: `Workflow supervisor '${config.id}' failed to ${action} persisted snapshot: ${formatUnknownMessage(error)}`, + recoverable: true, + detail: normalizeDiagnosticDetail({ + supervisor: config.id, + action, + error: formatUnknownMessage(error), + }), + }; + } + + function createRecoveryDiagnostic( + workflowName: string, + command: string, + result: Extract, + ): WorkflowSupervisorDiagnostic { + return { + code: "workflowSupervisor.recoveryCommandFailed", + message: `Workflow supervisor '${config.id}' recovery retry failed for workflow '${workflowName}' command '${command}'.`, + recoverable: result.diagnostics.some( + (diagnostic) => diagnostic.recoverable === true, + ), + workflow: workflowName, + command, + detail: normalizeDiagnosticDetail({ + supervisor: config.id, + workflow: workflowName, + command, + diagnostics: result.diagnostics, + }), + }; + } + + function findRecoverableFailedCommand( + workflow: Workflow, + ): string | undefined { + for (let index = workflow.history.length - 1; index >= 0; index -= 1) { + const entry = workflow.history[index]; + + if (entry.type !== "command.failed") { + continue; + } + + if ( + !entry.diagnostics?.some( + (diagnostic) => diagnostic.recoverable === true, + ) + ) { + continue; + } + + return entry.command; + } + + return undefined; + } + + function retryWorkflowCommand( + workflow: Workflow, + command: string, + ): Promise { + return ( + workflow.retry as (command: string) => Promise + )(command); + } + + function restore(snapshotInput: unknown): void { + const restoredSnapshot = normalizeWorkflowSupervisorSnapshot(snapshotInput); + + if (restoredSnapshot.id !== config.id) { + throw new Error( + "$workflowSupervisor restore snapshot id must match supervisor id.", + ); + } + + const supervisorDiagnostics = [...restoredSnapshot.diagnostics]; + + for (const [name, workflowSnapshot] of Object.entries( + restoredSnapshot.workflows, + )) { + const workflow = workflows.get(name); + + if (!workflow) { + supervisorDiagnostics.push( + createUnknownSnapshotWorkflowDiagnostic(name), + ); + + continue; + } + + workflow.restore(workflowSnapshot); + } + + status = restoredSnapshot.status; + replaceArray(diagnostics, supervisorDiagnostics); + } + + async function persist(): Promise< + WorkflowSupervisorSnapshot> + > { + const supervisorSnapshot = snapshot(); + + if (!config.persistence) { + return supervisorSnapshot; + } + + status = "persisting"; + + try { + await config.persistence.save(config.id, supervisorSnapshot); + status = "idle"; + + return supervisorSnapshot; + } catch (error) { + status = "failed"; + appendSupervisorDiagnostic( + createPersistenceDiagnostic( + "workflowSupervisor.persistenceSaveFailed", + "save", + error, + ), + ); + + throw error; + } + } + + async function load(): Promise< + | WorkflowSupervisorSnapshot> + | undefined + > { + if (!config.persistence) { + return undefined; + } + + status = "recovering"; + + try { + const persistedSnapshot = await config.persistence.load(config.id); + + if (!persistedSnapshot) { + status = "idle"; + + return undefined; + } + + restore(persistedSnapshot); + status = "idle"; + + return snapshot(); + } catch (error) { + status = "failed"; + appendSupervisorDiagnostic( + createPersistenceDiagnostic( + "workflowSupervisor.persistenceLoadFailed", + "load", + error, + ), + ); + + throw error; + } + } + + async function persistAfterCommand( + result: WorkflowCommandResult, + ): Promise> { + try { + await persist(); + } catch { + return result; + } + + return result; + } + + function getWorkflowOrThrow(name: string): Workflow { + const workflow = getWorkflowOrRecordDiagnostic(name); + + if (!workflow) { + throw new Error( + `$workflowSupervisor workflow '${name}' is not registered.`, + ); + } + + if ((config.persistencePolicy ?? "manual") !== "after-command") { + return workflow; + } + + let exposedWorkflow = exposedWorkflows.get(name); + + if (!exposedWorkflow) { + exposedWorkflow = new Proxy(workflow, { + get(target, property, receiver) { + if (property === "run") { + return ( + command: string, + input?: unknown, + options?: WorkflowCommandOptions, + ) => + runWorkflowCommand(target, command, input, options).then( + persistAfterCommand, + ); + } + + return Reflect.get(target, property, receiver) as unknown; + }, + }); + exposedWorkflows.set(name, exposedWorkflow); + } + + return exposedWorkflow; + } + + function getWorkflowOrRecordDiagnostic( + workflowName: unknown, + command?: string, + ): Workflow | undefined { + if (!isString(workflowName) || !workflowName) { + appendSupervisorDiagnostic( + createMissingWorkflowDiagnostic(workflowName, command), + ); + + return undefined; + } + + const workflow = workflows.get(workflowName); + + if (!workflow) { + appendSupervisorDiagnostic( + createMissingWorkflowDiagnostic(workflowName, command), + ); + + return undefined; + } + + return workflow; + } + + const supervisor: WorkflowSupervisor = { + id: config.id, + get status() { + return status; + }, + diagnostics, + ready: + config.recovery?.restoreOnStart === true && config.persistence + ? load().catch(() => undefined) + : Promise.resolve(undefined), + workflow(name) { + return getWorkflowOrThrow(name) as WorkflowFromDefinition< + TWorkflows[typeof name] + >; + }, + cancelAll() { + let cancelled = 0; + + for (const workflow of workflows.values()) { + cancelled += workflow.cancel(); + } + + return cancelled; + }, + snapshot, + restore, + persist, + load, + async recover() { + status = "recovering"; + + let recovered = false; + let failed = false; + + for (const [workflowName, workflow] of workflows) { + const command = findRecoverableFailedCommand(workflow); + + if (!command) { + continue; + } + + recovered = true; + + const result = await retryWorkflowCommand(workflow, command); + + if (!result.ok) { + failed = true; + appendSupervisorDiagnostic( + createRecoveryDiagnostic(workflowName, command, result), + ); + } + } + + status = failed ? "failed" : "idle"; + + return recovered ? snapshot() : undefined; + }, + }; + + return supervisor; +} + +export function createWorkflowWorkerHost< + TWorkflows extends Record, +>(config: WorkflowWorkerHostConfig): WorkflowWorkerHost { + if (!isObject(config)) { + throw new Error("Workflow worker host requires a config object."); + } + + if (!isObject(config.workflows) || isArray(config.workflows)) { + throw new Error("Workflow worker host requires workflows."); + } + + const workflows = new Map(); + const publishSnapshots = config.publishSnapshots !== false; + + for (const [name, workflow] of Object.entries(config.workflows)) { + if (!isString(name) || !name) { + throw new Error("Workflow worker host workflow names must be non-empty."); + } + + if (!isWorkflowInstance(workflow)) { + throw new Error( + "Workflow worker host workflows must be workflow instances.", + ); + } + + workflows.set(name, workflow); + } + + function snapshot(): Record> { + const snapshots: Record> = {}; + + for (const [name, workflow] of workflows) { + snapshots[name] = workflow.snapshot() as WorkflowSnapshot; + } + + return snapshots; + } + + function publish(post: (message: WorkflowWorkerMessage) => void): void { + if (!publishSnapshots) { + return; + } + + post({ + type: "angular-ts:workflow-worker:snapshot", + snapshot: snapshot(), + }); + } + + async function run(request: WorkflowWorkerRequest): Promise { + const workflow = getWorkflowWorkerWorkflow(workflows, request.workflow); + + if (!workflow) { + return createWorkflowWorkerCommandFailure( + "workflowWorker.missingWorkflow", + isString(request.workflow) && request.workflow + ? `Workflow worker host does not have workflow '${request.workflow}'.` + : "Workflow worker host requires a workflow name.", + request.command, + ); + } + + if (!isString(request.command) || !request.command) { + return createWorkflowWorkerCommandFailure( + "workflowWorker.invalidCommand", + "Workflow worker host requires a command name.", + undefined, + ); + } + + return runWorkerWorkflowCommand(workflow, request.command, request.input); + } + + function send(request: WorkflowWorkerRequest): WorkflowSendResult { + const workflow = getWorkflowWorkerWorkflow(workflows, request.workflow); + + if (!workflow || !isString(request.event) || !request.event) { + return { + ok: false, + status: "missing-workflow", + type: isString(request.event) ? request.event : "", + payload: request.payload, + }; + } + + return sendWorkerWorkflowEvent(workflow, request.event, request.payload); + } + + function restore( + request: WorkflowWorkerRequest, + ): Record> { + const snapshots = getWorkflowWorkerRestoreSnapshots(request.snapshot); + + for (const [name, workflowSnapshot] of Object.entries(snapshots)) { + const workflow = workflows.get(name); + + if (!workflow) { + continue; + } + + workflow.restore(workflowSnapshot); + } + + return snapshot(); + } + + async function handle( + message: unknown, + post: (message: WorkflowWorkerMessage) => void, + ): Promise { + if (!isWorkflowWorkerRequest(message)) { + return; + } + + try { + let result: unknown; + let shouldPublish = false; + + if (message.operation === "run") { + result = await run(message); + shouldPublish = true; + } else if (message.operation === "send") { + result = send(message); + shouldPublish = true; + } else if (message.operation === "snapshot") { + result = snapshot(); + } else { + result = restore(message); + shouldPublish = true; + } + + post({ + type: "angular-ts:workflow-worker:response", + id: message.id, + ok: true, + result, + }); + + if (shouldPublish) { + publish(post); + } + } catch (error) { + post({ + type: "angular-ts:workflow-worker:response", + id: message.id, + ok: false, + error: createDiagnostic( + "workflowWorker.requestFailed", + formatUnknownMessage(error), + undefined, + true, + ), + }); + } + } + + return { + handle, + snapshot, + }; +} + +export function createWorkflowWorkerClient( + connection: WorkerConnection, +): WorkflowWorkerClient { + const postMessageMethod = isObject(connection) + ? (connection as Partial).postMessage + : undefined; + const onMessageMethod = isObject(connection) + ? (connection as Partial).onMessage + : undefined; + + if ( + !isObject(connection) || + !isFunction(postMessageMethod) || + !isFunction(onMessageMethod) + ) { + throw new Error("Workflow worker client requires a WorkerConnection."); + } + + const postMessage = postMessageMethod.bind(connection) as ( + message: WorkflowWorkerRequest, + ) => void; + let nextRequestId = 1; + let disposed = false; + let latestSnapshot: Record> | undefined; + const pending = new Map< + string, + { + reject(error: Error): void; + resolve(value: unknown): void; + } + >(); + const snapshotListeners = new Set< + (snapshot: Record>) => void + >(); + const stopListening = onMessageMethod.call(connection, (data) => { + if (isWorkflowWorkerResponse(data)) { + const request = pending.get(data.id); + + if (!request) { + return; + } + + pending.delete(data.id); + + if (data.ok) { + request.resolve(data.result); + } else { + request.reject( + createWorkflowWorkerError( + data.error ?? + createDiagnostic( + "workflowWorker.requestFailed", + "Workflow worker request failed.", + undefined, + true, + ), + ), + ); + } + + return; + } + + if (isWorkflowWorkerSnapshotMessage(data)) { + latestSnapshot = data.snapshot; + + for (const listener of snapshotListeners) { + listener(data.snapshot); + } + + return; + } + }); + + function request( + operation: WorkflowWorkerRequestOperation, + requestData: Omit = {}, + ): Promise { + if (disposed) { + return Promise.reject( + createWorkflowWorkerError( + createDiagnostic( + "workflowWorker.clientDisposed", + "Workflow worker client is disposed.", + undefined, + true, + ), + ), + ); + } + + const id = `workflow-worker-${String(nextRequestId++)}`; + const message: WorkflowWorkerRequest = { + ...requestData, + type: "angular-ts:workflow-worker:request", + id, + operation, + }; + + return new Promise((resolve, reject) => { + pending.set(id, { + resolve(value) { + resolve(value as TResult); + }, + reject, + }); + postMessage(message); + }); + } + + return { + get latestSnapshot() { + return latestSnapshot; + }, + run(workflow, command, input) { + return request("run", { + workflow, + command, + input, + }); + }, + send(workflow, event, payload) { + return request("send", { + workflow, + event, + payload, + }); + }, + async snapshot() { + const workerSnapshot = + await request>>("snapshot"); + + latestSnapshot = workerSnapshot; + + return workerSnapshot; + }, + async restore(snapshotInput) { + const restoredSnapshot = await request< + Record> + >("restore", { + snapshot: snapshotInput, + }); + + latestSnapshot = restoredSnapshot; + + return restoredSnapshot; + }, + onSnapshot(callback) { + snapshotListeners.add(callback); + + return () => { + snapshotListeners.delete(callback); + }; + }, + dispose() { + disposed = true; + stopListening(); + + for (const request of pending.values()) { + request.reject( + createWorkflowWorkerError( + createDiagnostic( + "workflowWorker.clientDisposed", + "Workflow worker client is disposed.", + undefined, + true, + ), + ), + ); + } + + pending.clear(); + snapshotListeners.clear(); + }, + }; +} + +function createWorkflowWorkerError(diagnostic: WorkflowDiagnostic): Error & { + diagnostic: WorkflowDiagnostic; +} { + const error = new Error(diagnostic.message) as Error & { + diagnostic: WorkflowDiagnostic; + }; + + error.diagnostic = diagnostic; + + return error; +} + +function isWorkflowWorkerRequest( + value: unknown, +): value is WorkflowWorkerRequest { + if (!isObject(value)) { + return false; + } + + const candidate = value as Partial; + + return ( + candidate.type === "angular-ts:workflow-worker:request" && + isString(candidate.id) && + candidate.id.length > 0 && + (candidate.operation === "run" || + candidate.operation === "send" || + candidate.operation === "snapshot" || + candidate.operation === "restore") + ); +} + +function isWorkflowWorkerResponse( + value: unknown, +): value is WorkflowWorkerResponse { + if (!isObject(value)) { + return false; + } + + const candidate = value as Partial; + + return ( + candidate.type === "angular-ts:workflow-worker:response" && + isString(candidate.id) && + candidate.id.length > 0 && + isBoolean(candidate.ok) + ); +} + +function isWorkflowWorkerSnapshotMessage( + value: unknown, +): value is WorkflowWorkerSnapshotMessage { + if (!isObject(value)) { + return false; + } + + const candidate = value as Partial; + + return ( + candidate.type === "angular-ts:workflow-worker:snapshot" && + isObject(candidate.snapshot) && + !isArray(candidate.snapshot) + ); +} + +function getWorkflowWorkerWorkflow( + workflows: Map, + name: unknown, +): Workflow | undefined { + if (!isString(name) || !name) { + return undefined; + } + + return workflows.get(name); +} + +function runWorkerWorkflowCommand( + workflow: Workflow, + command: string, + input: unknown, +): Promise { + return ( + workflow.run as ( + command: string, + input?: unknown, + ) => Promise + )(command, input); +} + +function sendWorkerWorkflowEvent( + workflow: Workflow, + event: string, + payload: unknown, +): WorkflowSendResult { + return ( + workflow.send as (event: string, payload?: unknown) => WorkflowSendResult + )(event, payload); +} + +function createWorkflowWorkerCommandFailure( + code: string, + message: string, + command?: string, +): WorkflowCommandResult { + return { + ok: false, + status: "rejected", + diagnostics: [createDiagnostic(code, message, command, true)], + }; +} + +function getWorkflowWorkerRestoreSnapshots( + snapshotInput: unknown, +): Record { + if (!isObject(snapshotInput) || isArray(snapshotInput)) { + throw new Error("Workflow worker restore requires a snapshot object."); + } + + if ( + hasOwn(snapshotInput, "workflows") && + isObject((snapshotInput as { workflows?: unknown }).workflows) && + !isArray((snapshotInput as { workflows?: unknown }).workflows) + ) { + return (snapshotInput as { workflows: Record }).workflows; + } + + return snapshotInput as Record; +} +function createWorkflowSupervisorRegistry< + TWorkflows extends WorkflowSupervisorWorkflowMap, +>( + $workflow: WorkflowService, + config: WorkflowSupervisorConfig, +): Map { + assertWorkflowSupervisorConfig(config); + + const registry = new Map(); + const workflowEntries = normalizeWorkflowSupervisorEntries( + (config as { workflows?: unknown }).workflows, + ); + + if (!workflowEntries.length) { + throw new Error("$workflowSupervisor requires at least one workflow."); + } + + for (const [name, definition] of workflowEntries) { + if (!isString(name) || !name) { + throw new Error( + "$workflowSupervisor workflow names must be non-empty strings.", + ); + } + + if (registry.has(name)) { + throw new Error(`$workflowSupervisor duplicate workflow name '${name}'.`); + } + + registry.set(name, createWorkflowSupervisorWorkflow($workflow, definition)); + } + + return registry; +} + +function assertWorkflowSupervisorConfig(config: unknown): void { + if (!isObject(config)) { + throw new Error("$workflowSupervisor requires a config object."); + } + + const id = (config as { id?: unknown }).id; + + if (!isString(id) || !id) { + throw new Error("$workflowSupervisor requires a non-empty id."); + } + + const persistencePolicy = (config as { persistencePolicy?: unknown }) + .persistencePolicy; + + if ( + persistencePolicy !== undefined && + persistencePolicy !== "manual" && + persistencePolicy !== "after-command" + ) { + throw new Error( + "$workflowSupervisor persistencePolicy must be 'manual' or 'after-command'.", + ); + } + + const recovery = (config as { recovery?: unknown }).recovery; + + if (recovery === undefined) { + return; + } + + if (!isObject(recovery) || isArray(recovery)) { + throw new Error("$workflowSupervisor recovery must be an object."); + } + + const recoveryPolicy = recovery as WorkflowSupervisorRecoveryPolicy; + + if ( + recoveryPolicy.restoreOnStart !== undefined && + !isBoolean(recoveryPolicy.restoreOnStart) + ) { + throw new Error( + "$workflowSupervisor recovery.restoreOnStart must be a boolean.", + ); + } +} + +function normalizeWorkflowSupervisorEntries( + workflows: unknown, +): Array<[string, unknown]> { + if (isArray(workflows)) { + return workflows.map(normalizeWorkflowSupervisorEntry); + } + + if (!isObject(workflows)) { + throw new Error("$workflowSupervisor requires workflows."); + } + + return Object.entries(workflows); +} + +function normalizeWorkflowSupervisorEntry(entry: unknown): [string, unknown] { + if (isArray(entry)) { + return [entry[0] as string, entry[1]]; + } + + if (isObject(entry)) { + const record = entry as { + config?: unknown; + name?: unknown; + workflow?: unknown; + }; + + if (hasOwn(record, "workflow")) { + return [record.name as string, record.workflow]; + } + + if (hasOwn(record, "config")) { + return [record.name as string, record.config]; + } + } + + throw new Error( + "$workflowSupervisor workflow entries must be tuples or objects.", + ); +} + +function createWorkflowSupervisorWorkflow( + $workflow: WorkflowService, + definition: unknown, +): Workflow { + if (isWorkflowInstance(definition)) { + return definition; + } + + if (!isObject(definition)) { + throw new Error( + "$workflowSupervisor workflow must be a workflow instance or config object.", + ); + } + + return $workflow(definition as WorkflowConfig); +} + +function isWorkflowInstance(value: unknown): value is Workflow { + const workflow = value as Partial | undefined; + + return ( + isObject(workflow) && + isString(workflow.id) && + isString(workflow.current) && + isObject(workflow.data) && + isArray(workflow.diagnostics) && + isArray(workflow.history) && + isFunction(workflow.send) && + isFunction(workflow.can) && + isFunction(workflow.matches) && + isFunction(workflow.run) && + isFunction(workflow.retry) && + isFunction(workflow.cancel) && + isFunction(workflow.snapshot) && + isFunction(workflow.restore) + ); +} + +function normalizeWorkflowSupervisorSnapshot( + snapshot: unknown, +): WorkflowSupervisorSnapshot { + assertWorkflowSupervisorSnapshot(snapshot); + + const diagnostics = normalizeWorkflowSupervisorDiagnostics( + snapshot.diagnostics, + ); + + return { + version: 1, + id: snapshot.id, + status: normalizeRestoredSupervisorStatus(snapshot.status, diagnostics), + workflows: snapshot.workflows, + diagnostics, + updatedAt: snapshot.updatedAt, + }; +} + +function normalizeRestoredSupervisorStatus( + status: WorkflowSupervisorStatus, + diagnostics: WorkflowSupervisorDiagnostic[], +): WorkflowSupervisorStatus { + if (status === "failed") { + return "failed"; + } + + if ( + status === "running" || + status === "persisting" || + status === "recovering" + ) { + return diagnostics.some((diagnostic) => diagnostic.recoverable === false) + ? "failed" + : "idle"; + } + + return status; +} + +function assertWorkflowSupervisorSnapshot( + snapshot: unknown, +): asserts snapshot is WorkflowSupervisorSnapshot { + if (!isObject(snapshot)) { + throw new Error("$workflowSupervisor restore requires a snapshot object."); + } + + const candidate = snapshot as Partial; + + if (candidate.version !== 1) { + throw new Error( + "$workflowSupervisor restore requires a version 1 snapshot.", + ); + } + + if (!isString(candidate.id) || !candidate.id) { + throw new Error("$workflowSupervisor restore requires a non-empty id."); + } + + if (!isWorkflowSupervisorStatus(candidate.status)) { + throw new Error("$workflowSupervisor restore requires a valid status."); + } + + if (!isObject(candidate.workflows) || isArray(candidate.workflows)) { + throw new Error("$workflowSupervisor restore requires workflows."); + } + + if (!isArray(candidate.diagnostics)) { + throw new Error("$workflowSupervisor restore requires diagnostics."); + } + + if (!isNumber(candidate.updatedAt) || !Number.isFinite(candidate.updatedAt)) { + throw new Error("$workflowSupervisor restore requires updatedAt."); + } +} + +function isWorkflowSupervisorStatus( + value: unknown, +): value is WorkflowSupervisorStatus { + return ( + value === "idle" || + value === "running" || + value === "persisting" || + value === "recovering" || + value === "failed" + ); +} + +function normalizeWorkflowSupervisorDiagnostics( + supervisorDiagnostics: unknown, +): WorkflowSupervisorDiagnostic[] { + /* istanbul ignore next: restore validation enforces diagnostics arrays before normalization. */ + if (!isArray(supervisorDiagnostics)) { + return []; + } + + return supervisorDiagnostics.map((diagnostic) => { + if (!isObject(diagnostic)) { + return { + code: "workflowSupervisor.diagnostic", + message: formatUnknownMessage(diagnostic), + recoverable: true, + }; + } + + const candidate = diagnostic as Partial; + + return { + code: isString(candidate.code) + ? candidate.code + : "workflowSupervisor.diagnostic", + message: isString(candidate.message) + ? candidate.message + : "Workflow supervisor diagnostic.", + recoverable: isBoolean(candidate.recoverable) + ? candidate.recoverable + : undefined, + workflow: isString(candidate.workflow) ? candidate.workflow : undefined, + command: isString(candidate.command) ? candidate.command : undefined, + detail: normalizeDiagnosticDetail(candidate.detail), + }; + }); +} + +export function indexedDbWorkflowSupervisorPersistence< + TSnapshot extends WorkflowSupervisorSnapshot = WorkflowSupervisorSnapshot, +>( + config: WorkflowSupervisorIndexedDbPersistenceConfig = {}, +): WorkflowSupervisorPersistence { + const database = normalizeWorkflowSupervisorIndexedDbName( + config.database, + "database", + "angular-ts-workflows", + ); + const store = normalizeWorkflowSupervisorIndexedDbName( + config.store, + "store", + "supervisorSnapshots", + ); + const version = normalizeWorkflowSupervisorIndexedDbVersion(config.version); + const indexedDBFactory = config.indexedDB ?? globalThis.indexedDB; + + return { + async load(id) { + const databaseHandle = await openWorkflowSupervisorIndexedDb( + indexedDBFactory, + database, + store, + version, + ); + + try { + const transaction = databaseHandle.transaction(store, "readonly"); + const objectStore = transaction.objectStore(store); + const value = await workflowSupervisorIdbRequest( + objectStore.get(id) as IDBRequest, + ); + + await workflowSupervisorIdbTransaction(transaction); + + return value; + } finally { + databaseHandle.close(); + } + }, + async save(id, snapshot) { + const databaseHandle = await openWorkflowSupervisorIndexedDb( + indexedDBFactory, + database, + store, + version, + ); + + try { + const transaction = databaseHandle.transaction(store, "readwrite"); + const objectStore = transaction.objectStore(store); + + await workflowSupervisorIdbRequest(objectStore.put(snapshot, id)); + await workflowSupervisorIdbTransaction(transaction); + } finally { + databaseHandle.close(); + } + }, + async remove(id) { + const databaseHandle = await openWorkflowSupervisorIndexedDb( + indexedDBFactory, + database, + store, + version, + ); + + try { + const transaction = databaseHandle.transaction(store, "readwrite"); + const objectStore = transaction.objectStore(store); + + await workflowSupervisorIdbRequest(objectStore.delete(id)); + await workflowSupervisorIdbTransaction(transaction); + } finally { + databaseHandle.close(); + } + }, + }; +} + +function normalizeWorkflowSupervisorIndexedDbName( + value: unknown, + field: "database" | "store", + fallback: string, +): string { + if (value === undefined) { + return fallback; + } + + if (!isString(value) || !value) { + throw new Error( + `$workflowSupervisor IndexedDB ${field} must be non-empty.`, + ); + } + + return value; +} + +function normalizeWorkflowSupervisorIndexedDbVersion(value: unknown): number { + if (value === undefined) { + return 1; + } + + if (!isNumber(value) || !Number.isInteger(value) || value < 1) { + throw new Error( + "$workflowSupervisor IndexedDB version must be a positive integer.", + ); + } + + return value; +} + +function openWorkflowSupervisorIndexedDb( + indexedDBFactory: IDBFactory | undefined, + database: string, + store: string, + version: number, +): Promise { + if (!indexedDBFactory) { + return Promise.reject( + new Error( + "$workflowSupervisor IndexedDB persistence requires indexedDB.", + ), + ); + } + + return new Promise((resolve, reject) => { + const request = indexedDBFactory.open(database, version); -export interface WorkflowSnapshot< - TData extends object = Record, -> { - version: 1; - id: string; - current: WorkflowMode; - data: TData; - diagnostics: WorkflowDiagnostic[]; - history: WorkflowHistoryEntry[]; -} + request.onupgradeneeded = () => { + const databaseHandle = request.result; -export type WorkflowSnapshotMigration< - TData extends object = Record, -> = (snapshot: unknown) => WorkflowSnapshot; + if (!databaseHandle.objectStoreNames.contains(store)) { + databaseHandle.createObjectStore(store); + } + }; + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error ?? new Error("IndexedDB open failed.")); + }; + request.onblocked = () => { + reject(new Error("IndexedDB open was blocked.")); + }; + }); +} -export interface Workflow< - TData extends object = Record, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, -> { - id: string; - current: WorkflowMode; - data: TData; - diagnostics: WorkflowDiagnostic[]; - history: WorkflowHistoryEntry[]; - send>( - type: TType, - ...payload: undefined extends TEvents[TType] - ? [payload?: TEvents[TType]] - : [payload: TEvents[TType]] - ): boolean; - can(type: Extract): boolean; - matches(mode: WorkflowMode): boolean; - run: WorkflowRun; - retry: WorkflowReplay; - repeat: WorkflowReplay; - cancel(command?: string): number; - snapshot(): WorkflowSnapshot; - restore(snapshot: unknown): void; +function workflowSupervisorIdbRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error ?? new Error("IndexedDB request failed.")); + }; + }); } -export interface WorkflowService { - < - TData extends object = Record, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, - >( - config: WorkflowConfig, - ): Workflow; - < - TData extends object = Record, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, - >( - scope: ng.Scope, - config: WorkflowConfig, - ): Workflow; +function workflowSupervisorIdbTransaction( + transaction: IDBTransaction, +): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(transaction.error ?? new Error("IndexedDB transaction failed.")); + }; + transaction.onabort = () => { + reject(transaction.error ?? new Error("IndexedDB transaction aborted.")); + }; + }); } -type WorkflowTarget< +function createDefaultWorkflowStateEngine< TData extends object, TEvents extends object, - TCommands extends object, -> = Workflow & ScopeProxyBindable; +>( + $machine: MachineService, + config: WorkflowConfig, +): WorkflowStateEngine { + const machineConfig = { + initial: config.initial, + data: config.data, + states: config.states, + } as unknown as MachineConfig; + const machine: Machine = $machine(machineConfig); -interface WorkflowArgs< - TData extends object, - TEvents extends object, - TCommands extends object, -> { - _scope?: ng.Scope; - _config: WorkflowConfig; + return { + get current() { + return machine.current; + }, + get data() { + return machine.data; + }, + send(type, ...payload) { + return mapMachineSendResultToWorkflowSendResult( + machine.send(type, ...payload), + ); + }, + can(type, ...payload) { + return machine.can(type, ...payload); + }, + matches(mode) { + return machine.matches(mode); + }, + snapshot() { + return machine.snapshot(); + }, + restore(snapshot) { + machine.restore(snapshot); + }, + }; } -interface WorkflowBinding< +function mapMachineSendResultToWorkflowSendResult< TData extends object, TEvents extends object, - TCommands extends object, -> { - _handler: Scope; - _proxy: Workflow; -} - -interface WorkflowRunState { - _cancel: (diagnostic: WorkflowDiagnostic) => void; - _cancelDiagnostic?: WorkflowDiagnostic; - _cancelPromise: Promise; - _cleanups: Array<() => void>; - _command: string; - _controller: AbortController; - _discardResult: boolean; - _done: boolean; -} +>(result: MachineSendResult): WorkflowSendResult { + const evidence = { + type: result.type, + from: result.from, + to: result.to, + data: result.data, + payload: result.payload, + detail: result.policyDecision, + }; -export class WorkflowProvider { - $get = [ - _machine, - ($machine: MachineService): WorkflowService => - createWorkflowFactory($machine) as WorkflowService, - ]; + return result.ok + ? { ...evidence, ok: true, status: result.status } + : { ...evidence, ok: false, status: result.status }; } -export function defineWorkflow< - TData extends object = Record, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, +function createWorkflowStateEngine< + TData extends object, + TEvents extends object, + TCommands extends object, >( + $machine: MachineService, config: WorkflowConfig, -): WorkflowConfig { - return config; -} +): WorkflowStateEngine { + const createDefaultEngine = () => + createDefaultWorkflowStateEngine( + $machine, + config as WorkflowConfig, + ); -export function defineCommand< - TData extends object = Record, - TInput = unknown, - TOutput = unknown, - TEvents extends object = MachineNoEvents, - TCommands extends object = WorkflowNoCommands, - TName extends string = string, ->( - command: WorkflowCommand, -): WorkflowCommand { - return command; + const engine = config.stateEngine + ? config.stateEngine({ + config, + createDefaultEngine, + }) + : createDefaultEngine(); + + assertWorkflowStateEngine(engine); + + return engine; } function createWorkflowFactory($machine: MachineService) { return function createWorkflow< TData extends object, - TEvents extends object = MachineNoEvents, + TEvents extends object = WorkflowNoEvents, TCommands extends object = WorkflowNoCommands, >( - scopeOrConfig: ng.Scope | WorkflowConfig, - maybeConfig?: WorkflowConfig, + config: WorkflowConfig, ): Workflow { - const { _scope: scope, _config: config } = normalizeWorkflowArgs( - scopeOrConfig, - maybeConfig, - ); - assertWorkflowConfig(config); - const machine = $machine({ - initial: config.initial, - data: config.data, - transitions: config.transitions, - } as MachineConfig); + const engine = createWorkflowStateEngine($machine, config); const diagnostics: WorkflowDiagnostic[] = []; const history: WorkflowHistoryEntry[] = []; const diagnosticLimit = normalizeEntryLimit( @@ -367,30 +2129,33 @@ function createWorkflowFactory($machine: MachineService) { const workflowTarget: WorkflowTarget = { id: config.id, get current() { - return machine.current; + return engine.current; }, get data() { - return machine.data; + return engine.data; }, diagnostics, history, - send(type: string, payload?: unknown): boolean { - const handled = machine.send( + send(type: string, payload?: unknown): WorkflowSendResult { + const result = engine.send( type as Extract, payload as never, ); - if (handled) { + if (result.ok) { scheduleWorkflowBindings(); } else { + const status = result.status; + appendDiagnostics( createDiagnostic( "workflow.invalidTransition", - `Workflow mode '${machine.current}' cannot handle transition '${type}'.`, + `Workflow mode '${engine.current}' cannot handle transition '${type}' (${status}).`, undefined, true, { - current: machine.current, + current: engine.current, + status, type, payload, }, @@ -399,13 +2164,16 @@ function createWorkflowFactory($machine: MachineService) { scheduleWorkflowBindings(); } - return handled; + return result; }, - can(type: string): boolean { - return machine.can(type as Extract); + can(type: string, payload?: unknown): boolean { + return engine.can( + type as Extract, + payload as never, + ); }, - matches(mode: WorkflowMode): boolean { - return machine.matches(mode); + matches(mode: MachineMode): boolean { + return engine.matches(mode); }, async run( command: string, @@ -483,6 +2251,7 @@ function createWorkflowFactory($machine: MachineService) { if (generation !== queueGeneration) { return { ok: false, + status: "cancelled", diagnostics: [ createDiagnostic( "workflow.commandCancelled", @@ -554,51 +2323,6 @@ function createWorkflowFactory($machine: MachineService) { replay.options, ); }, - repeat( - command?: string | WorkflowCommandOptions, - options?: WorkflowCommandOptions, - ): Promise> { - const replay = normalizeReplayArgs(command, options); - - if (replay.invalidCommand) { - return Promise.resolve( - failWithoutHistory([ - createDiagnostic( - "workflow.invalidCommand", - "Workflow command name must be a non-empty string.", - undefined, - true, - ), - ]), - ); - } - - const repeatEntry = findRepeatEntry(replay.command); - - if (!repeatEntry) { - return Promise.resolve( - failWithoutHistory([ - createDiagnostic( - "workflow.noCompletedCommand", - isString(replay.command) && replay.command - ? `Workflow command '${replay.command}' has no completed run to repeat.` - : "Workflow has no completed command to repeat.", - isString(replay.command) && replay.command - ? replay.command - : undefined, - true, - ), - ]), - ); - } - - return runWorkflowCommand( - workflowTarget, - repeatEntry.command, - getReplayInput(repeatEntry), - replay.options, - ); - }, cancel(command?: string): number { if (command !== undefined && (!isString(command) || !command)) { return 0; @@ -626,11 +2350,13 @@ function createWorkflowFactory($machine: MachineService) { return cancelled; }, snapshot(): WorkflowSnapshot { + const stateSnapshot = engine.snapshot(); + return { version: 1, id: config.id, - current: machine.current, - data: structuredClone(machine.data), + current: stateSnapshot.current, + data: structuredClone(stateSnapshot.data), diagnostics: structuredClone(diagnostics), history: structuredClone(history), }; @@ -647,7 +2373,7 @@ function createWorkflowFactory($machine: MachineService) { queueGeneration += 1; commandQueues.clear(); cancelRunningCommands(false); - machine.restore({ + engine.restore({ current: restoredSnapshot.current, data: restoredSnapshot.data, }); @@ -681,20 +2407,12 @@ function createWorkflowFactory($machine: MachineService) { }, }); - if (scope?.$handler) { - return createScope(workflowTarget, scope.$handler as Scope) as Workflow< - TData, - TEvents, - TCommands - >; - } - return workflowTarget; async function executeCommand( command: string, input: unknown, - handler: WorkflowCommand, + handler: WorkflowCommand, options?: WorkflowCommandOptions, ): Promise> { appendHistory({ @@ -716,9 +2434,9 @@ function createWorkflowFactory($machine: MachineService) { const commandPromise = Promise.resolve( (() => { - const data = createWorkflowDataProxy(machine.data, state); + const data = createWorkflowDataProxy(engine.data, state); - return handler({ + return invokeWorkflowCommand(handler, { workflow: createCommandWorkflow(getActiveWorkflow(), state, data), cleanup(callback) { if (!isFunction(callback)) { @@ -740,7 +2458,7 @@ function createWorkflowFactory($machine: MachineService) { _workflowCancel: diagnostic, })), ])) as - | WorkflowCommandResult + | WorkflowCommandReturn | TOutput | { _workflowCancel: WorkflowDiagnostic; @@ -760,6 +2478,7 @@ function createWorkflowFactory($machine: MachineService) { if (state._discardResult) { return { ok: false, + status: commandStatusFromDiagnostics([cancelled._workflowCancel]), diagnostics: [cancelled._workflowCancel], }; } @@ -773,7 +2492,7 @@ function createWorkflowFactory($machine: MachineService) { } const result = normalizeCommandResult( - commandValue as WorkflowCommandResult | TOutput, + commandValue as WorkflowCommandReturn | TOutput, ); if (result.diagnostics?.length) { @@ -803,6 +2522,7 @@ function createWorkflowFactory($machine: MachineService) { if (state._discardResult) { return { ok: false, + status: commandStatusFromDiagnostics([state._cancelDiagnostic]), diagnostics: [state._cancelDiagnostic], }; } @@ -865,7 +2585,7 @@ function createWorkflowFactory($machine: MachineService) { "diagnostics", "history", ]); - binding._handler._checkListenersForAllKeys(machine.data as never); + binding._handler._checkListenersForAllKeys(engine.data as never); binding._handler._checkListenersForAllKeys(diagnostics as never); binding._handler._checkListenersForAllKeys(history as never); } @@ -887,20 +2607,16 @@ function createWorkflowFactory($machine: MachineService) { id: nextHistoryId++, type: entry.type, command: entry.command, + input: normalizeHistoryValue(entry.input), + ...(hasOwn(entry, "output") + ? { output: normalizeHistoryValue(entry.output) } + : {}), + ...(entry.diagnostics + ? { diagnostics: normalizeDiagnostics(entry.diagnostics) } + : {}), }; - if (hasOwn(entry, "input")) { - historyEntry.input = normalizeHistoryValue(entry.input); - replayInputs.set(historyEntry.id, entry.input); - } - - if (hasOwn(entry, "output")) { - historyEntry.output = normalizeHistoryValue(entry.output); - } - - if (entry.diagnostics) { - historyEntry.diagnostics = normalizeDiagnostics(entry.diagnostics); - } + replayInputs.set(historyEntry.id, entry.input); history.push(historyEntry); trimHistory(); @@ -1094,26 +2810,6 @@ function createWorkflowFactory($machine: MachineService) { return undefined; } - function findRepeatEntry( - command?: string, - ): WorkflowHistoryEntry | undefined { - for (let index = history.length - 1; index >= 0; index -= 1) { - const entry = history[index]; - - if (entry.type !== "command.completed") { - continue; - } - - if (isString(command) && command && entry.command !== command) { - continue; - } - - return entry; - } - - return undefined; - } - function getReplayInput(entry: WorkflowHistoryEntry): unknown { return replayInputs.has(entry.id) ? replayInputs.get(entry.id) @@ -1176,6 +2872,7 @@ function createWorkflowFactory($machine: MachineService) { return { ok: false, + status: commandStatusFromDiagnostics(commandDiagnostics), diagnostics: commandDiagnostics.length ? commandDiagnostics : [ @@ -1197,6 +2894,7 @@ function createWorkflowFactory($machine: MachineService) { return { ok: false, + status: commandStatusFromDiagnostics(commandDiagnostics), diagnostics: commandDiagnostics, }; } @@ -1220,6 +2918,18 @@ function runWorkflowCommand( return runner.run(command, input, options); } +function invokeWorkflowCommand< + TInput, + TOutput, + TData extends object, + TEvents extends object, +>( + handler: WorkflowCommand, + context: WorkflowCommandContext, +): WorkflowCommandHandlerResult { + return handler(context); +} + function createCommandWorkflow< TData extends object, TEvents extends object, @@ -1236,17 +2946,30 @@ function createCommandWorkflow< } if (property === "send") { - return (...args: unknown[]) => - state._done - ? false - : (target.send as (...sendArgs: unknown[]) => boolean)(...args); + return (...args: unknown[]) => { + if (state._done) { + return { + ok: false, + status: "command-finished", + type: isString(args[0]) ? args[0] : "", + payload: args[1], + } satisfies WorkflowSendResult; + } + + return ( + target.send as unknown as ( + ...sendArgs: unknown[] + ) => WorkflowSendResult + )(...args); + }; } - if (property === "run" || property === "retry" || property === "repeat") { + if (property === "run" || property === "retry") { return (...args: unknown[]) => state._done ? Promise.resolve({ ok: false, + status: "cancelled", diagnostics: [ createDiagnostic( "workflow.commandCancelled", @@ -1442,12 +3165,12 @@ function isWorkflowDataProxyable(value: unknown): value is object { function getCommand< TData extends object, - TEvents extends object = MachineNoEvents, + TEvents extends object = WorkflowNoEvents, TCommands extends object = WorkflowNoCommands, >( config: WorkflowConfig, command: string, -): WorkflowCommand | undefined { +): WorkflowCommand | undefined { if (!config.commands || !hasOwn(config.commands, command)) { return undefined; } @@ -1455,45 +3178,56 @@ function getCommand< const handler = (config.commands as Record)[command]; return isFunction(handler) - ? (handler as WorkflowCommand) + ? (handler as WorkflowCommand) : undefined; } function normalizeCommandResult( - value: WorkflowCommandResult | TOutput, + value: WorkflowCommandReturn | TOutput, ): WorkflowCommandResult { if (isObject(value) && hasOwn(value, "ok")) { const result = value as { ok: unknown; output?: TOutput; diagnostics?: WorkflowDiagnostic[]; + status?: WorkflowCommandStatus; }; const ok = result.ok; if (ok === true) { return { ok: true, + status: "completed", output: result.output, diagnostics: normalizeOptionalDiagnostics(result.diagnostics), }; } if (ok === false) { + const normalizedDiagnostics = normalizeOptionalDiagnostics( + result.diagnostics, + ) ?? [ + createDiagnostic( + "workflow.commandFailed", + "Workflow command failed.", + undefined, + true, + ), + ]; + return { ok: false, - diagnostics: normalizeOptionalDiagnostics(result.diagnostics) ?? [ - createDiagnostic( - "workflow.commandFailed", - "Workflow command failed.", - undefined, - true, - ), - ], + status: normalizeCommandFailureStatus( + result.status, + normalizedDiagnostics, + ), + diagnostics: normalizedDiagnostics, }; } return { ok: false, + status: "failed", diagnostics: [ createDiagnostic( "workflow.invalidCommandResult", @@ -1508,12 +3242,59 @@ function normalizeCommandResult( return { ok: true, + status: "completed", output: value as TOutput, }; } +function normalizeCommandFailureStatus( + status: WorkflowCommandStatus | undefined, + diagnostics: WorkflowDiagnostic[], +): Exclude { + if ( + status === "failed" || + status === "cancelled" || + status === "timeout" || + status === "rejected" + ) { + return status; + } + + return commandStatusFromDiagnostics(diagnostics); +} + +function commandStatusFromDiagnostics( + diagnostics: WorkflowDiagnostic[], +): Exclude { + for (const diagnostic of diagnostics) { + if (diagnostic.code === "workflow.commandTimeout") { + return "timeout"; + } + } + + for (const diagnostic of diagnostics) { + if (diagnostic.code === "workflow.commandCancelled") { + return "cancelled"; + } + } + + for (const diagnostic of diagnostics) { + if ( + diagnostic.code === "workflow.invalidCommand" || + diagnostic.code === "workflow.missingCommand" || + diagnostic.code === "workflow.invalidCommandOptions" || + diagnostic.code === "workflow.commandRunning" || + diagnostic.code === "workflow.noFailedCommand" + ) { + return "rejected"; + } + } + + return "failed"; +} + function normalizeDiagnostics( - diagnostics: WorkflowDiagnostic[] | undefined, + diagnostics: readonly WorkflowDiagnostic[] | undefined, ): WorkflowDiagnostic[] { if (!isArray(diagnostics)) { return []; @@ -1545,7 +3326,7 @@ function normalizeDiagnostics( } function normalizeOptionalDiagnostics( - diagnostics: WorkflowDiagnostic[] | undefined, + diagnostics: readonly WorkflowDiagnostic[] | undefined, ): WorkflowDiagnostic[] | undefined { if (!isArray(diagnostics)) { return undefined; @@ -1559,7 +3340,7 @@ function normalizeHistoryValue(value: unknown): unknown { } function normalizeHistory( - historyEntries: WorkflowHistoryEntry[] | undefined, + historyEntries: readonly WorkflowHistoryEntry[] | undefined, ): WorkflowHistoryEntry[] { if (!isArray(historyEntries)) { return []; @@ -1573,28 +3354,23 @@ function normalizeHistory( ? (entry as Partial) : {}; const id = allocateHistoryId(candidate.id); - const historyEntry: WorkflowHistoryEntry = { + return { id, type: normalizeHistoryType(candidate.type), command: isString(candidate.command) && candidate.command ? candidate.command : "unknown", + ...(hasOwn(candidate, "input") + ? { input: normalizeHistoryValue(candidate.input) } + : {}), + ...(hasOwn(candidate, "output") + ? { output: normalizeHistoryValue(candidate.output) } + : {}), + ...(hasOwn(candidate, "diagnostics") + ? { diagnostics: normalizeDiagnostics(candidate.diagnostics) } + : {}), }; - - if (hasOwn(candidate, "input")) { - historyEntry.input = normalizeHistoryValue(candidate.input); - } - - if (hasOwn(candidate, "output")) { - historyEntry.output = normalizeHistoryValue(candidate.output); - } - - if (hasOwn(candidate, "diagnostics")) { - historyEntry.diagnostics = normalizeDiagnostics(candidate.diagnostics); - } - - return historyEntry; }); function allocateHistoryId(value: unknown): number { @@ -1966,26 +3742,6 @@ function replaceArray(target: T[], source: T[]): void { target.splice(0, target.length, ...structuredClone(source)); } -function normalizeWorkflowArgs< - TData extends object, - TEvents extends object, - TCommands extends object, ->( - scopeOrConfig: ng.Scope | WorkflowConfig, - maybeConfig?: WorkflowConfig, -): WorkflowArgs { - if (maybeConfig) { - return { - _scope: scopeOrConfig as ng.Scope, - _config: maybeConfig, - }; - } - - return { - _config: scopeOrConfig as WorkflowConfig, - }; -} - function assertWorkflowConfig< TData extends object, TEvents extends object, @@ -2007,14 +3763,18 @@ function assertWorkflowConfig< throw new Error("$workflow requires a data object."); } - if (!isObject(config.transitions)) { - throw new Error("$workflow requires a transitions object."); + if (!isObject(config.states)) { + throw new Error("$workflow requires a states object."); } if (config.commands !== undefined && !isObject(config.commands)) { throw new Error("$workflow commands must be an object."); } + if (config.stateEngine !== undefined && !isFunction(config.stateEngine)) { + throw new Error("$workflow stateEngine must be a function."); + } + const concurrency = config.concurrency as unknown; if ( @@ -2044,6 +3804,34 @@ function assertWorkflowConfig< } } +function assertWorkflowStateEngine(value: unknown): void { + if (!isObject(value)) { + throw new Error("$workflow stateEngine must return an engine object."); + } + + const engine = value as Partial; + + if (!isString(engine.current) || !engine.current) { + throw new Error("$workflow stateEngine must expose a current mode."); + } + + if (!isObject(engine.data)) { + throw new Error("$workflow stateEngine must expose a data object."); + } + + if ( + !isFunction(engine.send) || + !isFunction(engine.can) || + !isFunction(engine.matches) || + !isFunction(engine.snapshot) || + !isFunction(engine.restore) + ) { + throw new Error( + "$workflow stateEngine must expose send, can, matches, snapshot, and restore.", + ); + } +} + function assertWorkflowSnapshot(snapshot: unknown): void { if (!isObject(snapshot)) { throw new Error("$workflow restore requires a snapshot object."); diff --git a/src/shared/utils.spec.ts b/src/shared/utils.spec.ts index ffd8270b7..17705e491 100644 --- a/src/shared/utils.spec.ts +++ b/src/shared/utils.spec.ts @@ -69,6 +69,7 @@ import { parseKeyValue, setHashKey, shallowCopy, + shouldHandleViewRetentionPause, simpleCompare, sliceArgs, snakeCase, @@ -106,6 +107,36 @@ describe("utility functions", () => { dealoc(element); }); + describe("shouldHandleViewRetentionPause()", () => { + it("should handle missing and non-object payloads as the default scheduler mode", () => { + expect(shouldHandleViewRetentionPause([])).toBeTrue(); + expect(shouldHandleViewRetentionPause(["payload"])).toBeTrue(); + }); + + it("should read mode payloads from either listener argument shape", () => { + const event = { name: "$viewRetentionPause" }; + + expect( + shouldHandleViewRetentionPause([{ _pause: "schedulers" }]), + ).toBeTrue(); + expect( + shouldHandleViewRetentionPause([event, { _pause: "schedulers" }]), + ).toBeTrue(); + expect( + shouldHandleViewRetentionPause([{ _pause: "background" }]), + ).toBeFalse(); + expect( + shouldHandleViewRetentionPause([event, { _pause: "background" }]), + ).toBeFalse(); + expect( + shouldHandleViewRetentionPause( + [event, { _pause: "background" }], + "background", + ), + ).toBeTrue(); + }); + }); + describe("hashKey()", () => { it("should use an existing `$hashKey`", () => { const obj = { $hashKey: "foo" }; @@ -1272,6 +1303,9 @@ describe("utility functions", () => { const proxy = { [isProxySymbol]: true, $target: target }; + expect(isProxySymbol).toBe( + Symbol.for("@angular-wave/angular.ts/isProxy"), + ); expect(isProxy(proxy)).toBe(true); expect(deProxy(proxy)).toBe(target); expect(deProxy(target)).toBe(target); @@ -1517,7 +1551,7 @@ describe("utility functions", () => { try { await expectAsync( instantiateWasm("/missing.wasm"), - ).toBeRejectedWithError("fetch failed"); + ).toBeRejectedWithError("WebAssembly fetch failed"); } finally { window.fetch = originalFetch; } diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 8cebba11b..560e6ced3 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -4,10 +4,44 @@ import { NodeType } from "./node.ts"; export type { ErrorHandlingConfig } from "./interface.ts"; -export const isProxySymbol = Symbol("isProxy"); +export const isProxySymbol = Symbol.for("@angular-wave/angular.ts/isProxy"); export type RuntimeFunction = (...args: never[]) => unknown; +export type ViewRetentionPauseMode = "none" | "background" | "schedulers"; + +export interface ViewRetentionPauseEvent { + _pause?: ViewRetentionPauseMode; +} + +export function shouldHandleViewRetentionPause( + args: readonly unknown[], + mode: ViewRetentionPauseMode = "schedulers", +): boolean { + if (!args.length) { + return true; + } + + const maybePayload = + args.find( + (arg) => + isObject(arg) && + typeof (arg as ViewRetentionPauseEvent)._pause !== "undefined", + ) ?? args[0]; + + if (!isObject(maybePayload)) { + return true; + } + + const maybeMode = (maybePayload as ViewRetentionPauseEvent)._pause; + + if (typeof maybeMode === "undefined") { + return true; + } + + return maybeMode === mode; +} + export type RuntimeConstructor = abstract new (...args: never[]) => unknown; type UnknownRecord = Record; @@ -1501,32 +1535,85 @@ export function startsWith(str: string, search: string): boolean { return str.startsWith(search); } -/** - * Loads and instantiates a WebAssembly module. - * Tries streaming first, then falls back. +/** @internal Standard-shaped options forwarded to WebAssembly compilation. */ +export interface WasmNativeCompileOptions { + builtins?: readonly string[]; + importedStringConstants?: string; +} + +/** @internal + * Loads and compiles a WebAssembly module. + * Tries streaming first, then falls back for response type failures. */ -export async function instantiateWasm( - src: string, - imports: WebAssembly.Imports = {}, -) { - const res = await fetch(src); +export async function compileWasm( + source: string | URL | Request | Response | BufferSource | WebAssembly.Module, + signal?: AbortSignal, + options?: WasmNativeCompileOptions, +): Promise { + if (source instanceof WebAssembly.Module) { + return source; + } - if (!res.ok) throw new Error("fetch failed"); + if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + const compile = WebAssembly.compile as ( + bytes: BufferSource, + options?: WasmNativeCompileOptions, + ) => Promise; - try { - const { instance, module } = await WebAssembly.instantiateStreaming( - res.clone(), - imports, - ); + return compile(source, options); + } - return { instance, exports: instance.exports, module }; - } catch { - /* empty */ + const response = + source instanceof Response + ? source.clone() + : await fetch(source instanceof Request ? source.clone() : source, { + signal, + }); + + if (!response.ok) { + const status = response.status + ? ` (${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""})` + : ""; + + throw new Error(`WebAssembly fetch failed${status}`); } - const bytes = await res.arrayBuffer(); + if (typeof WebAssembly.compileStreaming === "function") { + try { + const compileStreaming = WebAssembly.compileStreaming as ( + source: Promise | Response, + options?: WasmNativeCompileOptions, + ) => Promise; + + return await compileStreaming(response.clone(), options); + } catch (error) { + // A TypeError commonly means the server supplied an invalid Wasm MIME + // type. Compilation, linking, and start-function failures must not be + // retried because instantiation can have observable side effects. + if (!(error instanceof TypeError)) { + throw error; + } + } + } - const { instance, module } = await WebAssembly.instantiate(bytes, imports); + const bytes = await response.arrayBuffer(); + const compile = WebAssembly.compile as ( + bytes: BufferSource, + options?: WasmNativeCompileOptions, + ) => Promise; + + return compile(bytes, options); +} + +/** @internal Loads, compiles, and instantiates a WebAssembly module. */ +export async function instantiateWasm( + source: string | URL | Request | Response | BufferSource | WebAssembly.Module, + imports: WebAssembly.Imports = {}, + signal?: AbortSignal, + options?: WasmNativeCompileOptions, +) { + const module = await compileWasm(source, signal, options); + const instance = await WebAssembly.instantiate(module, imports); return { instance, exports: instance.exports, module }; } diff --git a/test/namespace-js-consumer.js b/test/namespace-js-consumer.js index 509219f33..402ac36fc 100644 --- a/test/namespace-js-consumer.js +++ b/test/namespace-js-consumer.js @@ -18,52 +18,9 @@ * DirectiveRestrict: ng.DirectiveRestrict, * DirectiveFactory: ng.DirectiveFactory, * NgModule: ng.NgModule, - * PublicLinkFn: ng.PublicLinkFn, - * PubSubProvider: ng.PubSubProvider, + * LinkFn: ng.LinkFn, * Scope: ng.Scope, - * ScopeService: ng.ScopeService, * TranscludeFn: ng.TranscludeFn, - * AnchorScrollProvider: ng.AnchorScrollProvider, - * AngularProvider: ng.AngularProvider, - * AngularServiceProvider: ng.AngularServiceProvider, - * AnimateProvider: ng.AnimateProvider, - * AriaProvider: ng.AriaProvider, - * CompileLifecycleProvider: ng.CompileLifecycleProvider, - * CompileProvider: ng.CompileProvider, - * ControllerProvider: ng.ControllerProvider, - * CookieProvider: ng.CookieProvider, - * EventBusProvider: ng.EventBusProvider, - * FilterProvider: ng.FilterProvider, - * ExceptionHandlerProvider: ng.ExceptionHandlerProvider, - * HttpParamSerializerProvider: ng.HttpParamSerializerProvider, - * HttpProvider: ng.HttpProvider, - * InterpolateProvider: ng.InterpolateProvider, - * LocationProvider: ng.LocationProvider, - * LogProvider: ng.LogProvider, - * MachineProvider: ng.MachineProvider, - * WorkflowProvider: ng.WorkflowProvider, - * ParseProvider: ng.ParseProvider, - * RestProvider: ng.RestProvider, - * RootScopeProvider: ng.RootScopeProvider, - * RouterProvider: ng.RouterProvider, - * SceDelegateProvider: ng.SceDelegateProvider, - * SceProvider: ng.SceProvider, - * SseProvider: ng.SseProvider, - * StateProvider: ng.StateProvider, - * StateRegistryProvider: ng.StateRegistryProvider, - * StreamProvider: ng.StreamProvider, - * TemplateCacheProvider: ng.TemplateCacheProvider, - * TemplateFactoryProvider: ng.TemplateFactoryProvider, - * TemplateRequestProvider: ng.TemplateRequestProvider, - * TransitionProvider: ng.TransitionProvider, - * TransitionsProvider: ng.TransitionsProvider, - * TransitionService: ng.TransitionService, - * ViewProvider: ng.ViewProvider, - * WasmProvider: ng.WasmProvider, - * WebComponentProvider: ng.WebComponentProvider, - * WebSocketProvider: ng.WebSocketProvider, - * WebTransportProvider: ng.WebTransportProvider, - * WorkerProvider: ng.WorkerProvider, * AnchorScrollService: ng.AnchorScrollService, * AnimateService: ng.AnimateService, * AnimationHandle: ng.AnimationHandle, @@ -71,20 +28,15 @@ * AnimationLifecycleCallback: ng.AnimationLifecycleCallback, * AriaService: ng.AriaService, * CompileService: ng.CompileService, - * CompileLifecycleService: ng.CompileLifecycleService, * ControllerService: ng.ControllerService, * CookieService: ng.CookieService, - * ElementService: ng.ElementService, * EventBusService: ng.EventBusService, * ExceptionHandlerService: ng.ExceptionHandlerService, * FilterFn: ng.FilterFn, * FilterFactory: ng.FilterFactory, * FilterService: ng.FilterService, * EntryFilterItem: ng.EntryFilterItem, - * DateFilterOptions: ng.DateFilterOptions, - * NumberFilterOptions: ng.NumberFilterOptions, * CurrencyFilterOptions: ng.CurrencyFilterOptions, - * RelativeTimeFilterOptions: ng.RelativeTimeFilterOptions, * HttpParamSerializerService: ng.HttpParamSerializerService, * HttpService: ng.HttpService, * InjectorService: ng.InjectorService, @@ -94,12 +46,6 @@ * MachineService: ng.MachineService, * WorkflowService: ng.WorkflowService, * ParseService: ng.ParseService, - * ProvideService: ng.ProvideService, - * PubSubService: ng.PubSubService, - * RootElementService: ng.RootElementService, - * RootScopeService: ng.RootScopeService, - * StateService: ng.StateService, - * StateRegistryService: ng.StateRegistryService, * SceService: ng.SceService, * SceDelegateService: ng.SceDelegateService, * SseService: ng.SseService, @@ -107,19 +53,13 @@ * SseConnection: ng.SseConnection, * RealtimeProtocolEventDetail: ng.RealtimeProtocolEventDetail, * RealtimeProtocolMessage: ng.RealtimeProtocolMessage, - * SseProtocolEventDetail: ng.SseProtocolEventDetail, - * SseProtocolMessage: ng.SseProtocolMessage, - * SwapModeType: ng.SwapModeType, + * SwapMode: ng.SwapMode, * TemplateCacheService: ng.TemplateCacheService, - * TemplateFactoryService: ng.TemplateFactoryService, * TemplateRequestService: ng.TemplateRequestService, * TransitionsService: ng.TransitionsService, - * ViewService: ng.ViewService, * WorkerService: ng.WorkerService, - * AngularService: ng.AngularService, * AnnotatedFactory: ng.AnnotatedFactory<(...args: never[]) => unknown>, * AnimationOptions: ng.AnimationOptions, - * NativeAnimationOptions: ng.NativeAnimationOptions, * AnimationPhase: ng.AnimationPhase, * AnimationPreset: ng.AnimationPreset, * AnimationPresetHandler: ng.AnimationPresetHandler, @@ -130,52 +70,32 @@ * ControllerConstructor: ng.ControllerConstructor, * CookieOptions: ng.CookieOptions, * CookieStoreOptions: ng.CookieStoreOptions, - * DocumentService: ng.DocumentService, * EntityClass: ng.EntityClass, * ErrorHandlingConfig: ng.ErrorHandlingConfig, * Expression: ng.Expression, * HttpMethod: ng.HttpMethod, - * HttpPromise: ng.HttpPromise, - * HttpProviderDefaults: ng.HttpProviderDefaults, + * HttpDefaults: ng.HttpDefaults, * HttpResponse: ng.HttpResponse, * HttpResponseStatus: ng.HttpResponseStatus, * Injectable: ng.Injectable<(...args: never[]) => unknown>, - * InjectionTokens: ng.InjectionTokens, * InterpolationFunction: ng.InterpolationFunction, - * InvocationDetail: ng.InvocationDetail, * ListenerFn: ng.ListenerFn, - * Machine: ng.Machine<{ roomId: string }>, - * MachineConfig: ng.MachineConfig<{ roomId: string }>, - * MachineEventMap: ng.MachineEventMap, - * MachineHooks: ng.MachineHooks<{ roomId: string }>, - * MachineMode: ng.MachineMode, - * MachineModeHooks: ng.MachineModeHooks<{ roomId: string }>, - * MachineNoEvents: ng.MachineNoEvents, - * MachineSnapshot: ng.MachineSnapshot<{ roomId: string }>, - * MachineTransition: ng.MachineTransition<{ roomId: string }, { roomId: string }>, - * MachineTransitionContext: ng.MachineTransitionContext<{ roomId: string }>, - * MachineTransitionHook: ng.MachineTransitionHook<{ roomId: string }>, - * MachineTransitionMap: ng.MachineTransitionMap<{ roomId: string }>, - * MachineTransitionResult: ng.MachineTransitionResult, - * Workflow: ng.Workflow<{ output: string }>, - * WorkflowCommand: ng.WorkflowCommand<{ output: string }, string, { file: string }>, - * WorkflowCommandContext: ng.WorkflowCommandContext<{ output: string }, string>, - * WorkflowCommandMap: ng.WorkflowCommandMap<{ output: string }>, - * WorkflowCommandOptions: ng.WorkflowCommandOptions, - * WorkflowConcurrencyPolicy: ng.WorkflowConcurrencyPolicy, - * WorkflowCommandResult: ng.WorkflowCommandResult<{ file: string }>, - * WorkflowConfig: ng.WorkflowConfig<{ output: string }>, - * WorkflowDiagnostic: ng.WorkflowDiagnostic, - * WorkflowHistoryEntry: ng.WorkflowHistoryEntry, - * WorkflowMode: ng.WorkflowMode, - * WorkflowNoCommands: ng.WorkflowNoCommands, - * WorkflowSnapshot: ng.WorkflowSnapshot<{ output: string }>, - * WorkflowSnapshotMigration: ng.WorkflowSnapshotMigration<{ output: string }>, - * WorkflowStatus: ng.WorkflowStatus, + * Machine: ng.Machine, + * MachineSendResult: ng.MachineSendResult, + * MachineSendStatus: ng.MachineSendStatus, + * MachineSnapshot: ng.MachineSnapshot, + * Workflow: ng.Workflow, + * WorkflowCommand: ng.WorkflowCommand, + * WorkflowCommandContext: ng.WorkflowCommandContext, + * WorkflowCommandDefinition: ng.WorkflowCommandDefinition, + * WorkflowResult: ng.WorkflowResult<{ file: string }>, + * WorkflowSnapshot: ng.WorkflowSnapshot, + * WorkflowSupervisor: ng.WorkflowSupervisor, + * WorkflowSupervisorConfig: ng.WorkflowSupervisorConfig, + * WorkflowSupervisorSnapshot: ng.WorkflowSupervisorSnapshot, * NgModelController: ng.NgModelController, - * RequestConfig: ng.RequestConfig, - * RequestShortcutConfig: ng.RequestShortcutConfig, - * RestDefinition: ng.RestDefinition, + * HttpRequestConfig: ng.HttpRequestConfig, + * HttpRequestOptions: ng.HttpRequestOptions, * RestFactory: ng.RestFactory, * RestBackend: ng.RestBackend, * RestCacheStore: ng.RestCacheStore, @@ -187,16 +107,22 @@ * CachedRestBackendOptions: ng.CachedRestBackendOptions, * RestService: ng.RestService, * ScopeEvent: ng.ScopeEvent, - * ServiceProvider: ng.ServiceProvider, + * RouterModuleDeclaration: ng.RouterModuleDeclaration, * StateDeclaration: ng.StateDeclaration, - * StateResolveArray: ng.StateResolveArray, - * StateResolveObject: ng.StateResolveObject, + * StatePolicyDeclaration: ng.StatePolicyDeclaration, + * RouteContract: ng.RouteContract, + * RouteMap: ng.RouteMap, + * RoutesOf: ng.RoutesOf<{ name: "admin" }>, + * ParamsOf: ng.ParamsOf<{ admin: { params: { id: string } } }, "admin">, + * ResolvesOf: ng.ResolvesOf<{ admin: { resolves: { user: { id: string } } } }, "admin">, + * RouterModule: ng.RouterModule<{ admin: {} }>, + * StateService: ng.StateService<{ admin: {} }>, * StorageBackend: ng.StorageBackend, * StorageType: ng.StorageType, * ConnectionConfig: ng.ConnectionConfig, * ConnectionEvent: ng.ConnectionEvent, * StreamService: ng.StreamService, - * Transition: ng.Transition, + * Transition: ng.Transition<{ admin: {} }, { to: "admin", from: "admin" }>, * Validator: ng.Validator, * ElementScopeOptions: ng.ElementScopeOptions, * AppComponentOptions: ng.AppComponentOptions, @@ -210,32 +136,31 @@ * WebSocketConfig: ng.WebSocketConfig, * WebSocketConnection: ng.WebSocketConnection, * WebSocketService: ng.WebSocketService, - * NativeWebTransport: ng.NativeWebTransport, * WebTransportBufferInput: ng.WebTransportBufferInput, - * WebTransportCertificateHash: ng.WebTransportCertificateHash, * WebTransportConfig: ng.WebTransportConfig, * WebTransportConnection: ng.WebTransportConnection, * WebTransportDatagramEvent: ng.WebTransportDatagramEvent, - * WebTransportOptions: ng.WebTransportOptions, * WebTransportReconnectEvent: ng.WebTransportReconnectEvent, * WebTransportRetryDelay: ng.WebTransportRetryDelay, * WebTransportService: ng.WebTransportService, - * WindowService: ng.WindowService, * WorkerConfig: ng.WorkerConfig, - * WorkerConnection: ng.WorkerConnection, - * WasmAbiExports: ng.WasmAbiExports, - * WasmInstantiationResult: ng.WasmInstantiationResult, - * WasmOptions: ng.WasmOptions, - * WasmScope: ng.WasmScope, - * WasmScopeAbi: ng.WasmScopeAbi, - * WasmScopeAbiImportObject: ng.WasmScopeAbiImportObject, - * WasmScopeAbiImports: ng.WasmScopeAbiImports, - * WasmScopeBindingOptions: ng.WasmScopeBindingOptions, - * WasmScopeOptions: ng.WasmScopeOptions, - * WasmScopeReference: ng.WasmScopeReference, - * WasmScopeUpdate: ng.WasmScopeUpdate, - * WasmScopeWatchOptions: ng.WasmScopeWatchOptions, + * WorkerError: ng.WorkerError, + * WorkerErrorCode: ng.WorkerErrorCode, + * WorkerHandle: ng.WorkerHandle, + * WorkerModelMessage: ng.WorkerModelMessage, + * WorkerRequest: ng.WorkerRequest, + * WorkerRequestOptions: ng.WorkerRequestOptions, + * WorkerResponse: ng.WorkerResponse, + * WasmBinding: ng.WasmBinding, + * WasmBindingOptions: ng.WasmBindingOptions, + * WasmError: ng.WasmError, + * WasmErrorCode: ng.WasmErrorCode, + * WasmLoadOptions: ng.WasmLoadOptions, + * WasmResource: ng.WasmResource, + * WasmResourceStatus: ng.WasmResourceStatus, * WasmService: ng.WasmService, + * WasmSource: ng.WasmSource, + * WasmTarget: ng.WasmTarget, * }} AngularTsNamespaceTypes */ @@ -254,7 +179,7 @@ export const tileClasses = Object.freeze({ export const tileClassValue = ["tile", tileClasses]; /** - * @param {ng.ScopeService} $scope + * @param {ng.Scope} $scope */ export function batchScopeUpdate($scope) { return $scope.$batch(() => { @@ -264,56 +189,67 @@ export function batchScopeUpdate($scope) { }); } +/** + * @typedef {{ data: { roomId: string }, events: { join: { roomId: string } }, state: "setup" | "waiting" }} SessionMachineContract + */ + /** * @param {ng.MachineService} $machine - * @returns {ng.Machine<{ roomId: string }, ng.MachineEventMap>} + * @returns {ng.Machine} */ export function createSessionMachine($machine) { - const machine = $machine({ + /** @type {ng.MachineConfig} */ + const config = { initial: "setup", data: { roomId: "", }, - transitions: { + states: { setup: { - /** - * @param {{ roomId: string }} data - * @param {unknown} payload - * @returns {ng.MachineTransitionResult} - */ - join(data, payload) { - if ( - typeof payload === "object" && - payload !== null && - "roomId" in payload && - typeof payload.roomId === "string" - ) { - data.roomId = payload.roomId; - } + on: { + join: { + to: "waiting", + /** + * @param {{ data: { roomId: string }, payload: unknown }} context + */ + update(context) { + const payload = context.payload; - return "waiting"; + if ( + typeof payload === "object" && + payload !== null && + "roomId" in payload && + typeof payload.roomId === "string" + ) { + context.data.roomId = payload.roomId; + } + }, + }, }, }, + waiting: {}, }, hooks: { enter: { /** - * @param {ng.MachineTransitionContext<{ roomId: string }>} context + * @param {{ data: { roomId: string }, to?: string }} context */ waiting(context) { - context.data.roomId = context.to; + context.data.roomId = context.to || "waiting"; }, }, /** - * @param {ng.MachineTransitionContext<{ roomId: string }>} context + * @param {{ machine: ng.Machine, from: "setup" | "waiting", to?: "setup" | "waiting" }} context */ transition(context) { - context.machine.matches(context.to); + context.machine.matches(context.to || context.from); }, }, - }); + }; + /** @type {ng.Machine} */ + const machine = $machine(config); - /** @type {ng.MachineSnapshot<{ roomId: string }>} */ + /** @type {ng.MachineSnapshot} */ const snapshot = machine.snapshot(); machine.restore(snapshot); @@ -326,32 +262,36 @@ export function createSessionMachine($machine) { * @returns {ng.NgModule} */ export function registerSessionMachine(module) { - /** @type {ng.MachineConfig<{ roomId: string }, ng.MachineEventMap>} */ + /** @type {any} */ const config = { initial: "setup", data: { roomId: "", }, - transitions: { + states: { setup: { - /** - * @param {{ roomId: string }} data - * @param {unknown} payload - * @returns {ng.MachineTransitionResult} - */ - join(data, payload) { - if ( - typeof payload === "object" && - payload !== null && - "roomId" in payload && - typeof payload.roomId === "string" - ) { - data.roomId = payload.roomId; - } + on: { + join: { + to: "waiting", + /** + * @param {{ data: { roomId: string }, payload: unknown }} context + */ + update(context) { + const payload = context.payload; - return "waiting"; + if ( + typeof payload === "object" && + payload !== null && + "roomId" in payload && + typeof payload.roomId === "string" + ) { + context.data.roomId = payload.roomId; + } + }, + }, }, }, + waiting: {}, }, }; @@ -360,46 +300,47 @@ export function registerSessionMachine(module) { /** * @param {ng.WorkflowService} $workflow - * @returns {Promise} + * @returns {Promise} */ export function createDocsWorkflow($workflow) { - /** @type {ng.Workflow<{ output: string }, ng.MachineEventMap, { build: ng.WorkflowCommand<{ output: string }, string, { file: string }, ng.MachineNoEvents> }>} */ - const workflow = $workflow({ + /** @type {any} */ + const config = { id: "docs-build", initial: "idle", data: { output: "", }, - transitions: { - idle: { - start() { - return "running"; - }, - }, - }, commands: { - /** - * @param {ng.WorkflowCommandContext<{ output: string }, string>} context - * @returns {ng.WorkflowCommandResult<{ file: string }>} - */ - build(context) { - context.data.output = context.input; - return { - ok: true, - output: { - file: context.data.output, + build: { + from: "idle", + pending: "running", + /** + * @param {{ input: string }} context + * @returns {{ file: string }} + */ + execute(context) { + return { + file: context.input, + }; + }, + success: { + to: "complete", + /** @param {{ data: { output: string }, output: { file: string } }} context */ + update(context) { + context.data.output = context.output.file; }, - }; + }, + failure: "failed", }, }, - }); + }; + /** @type {ng.Workflow} */ + const workflow = $workflow(/** @type {any} */ (config)); - /** @type {ng.WorkflowSnapshot<{ output: string }>} */ + /** @type {ng.WorkflowSnapshot} */ const snapshot = workflow.snapshot(); workflow.restore(snapshot); - void workflow.retry("build"); - void workflow.repeat("build"); return workflow.run("build", "index.html"); } @@ -409,60 +350,17 @@ export function createDocsWorkflow($workflow) { * @returns {ng.NgModule} */ export function registerDocsWorkflow(module) { - /** @type {ng.WorkflowConfig<{ output: string }, ng.MachineEventMap>} */ + /** @type {any} */ const config = { id: "docs-build", initial: "idle", data: { output: "", }, - transitions: {}, + states: { + idle: {}, + }, }; return module.workflow("docsWorkflow", config); } - -/** - * @param {ng.HttpProvider} $httpProvider - */ -export function configureHttp($httpProvider) { - $httpProvider.defaults.withCredentials = true; - $httpProvider.interceptors.push(() => ({})); - $httpProvider.xsrfTrustedOrigins.push("https://api.example.com"); -} - -/** - * @param {ng.AnimateProvider} $animateProvider - */ -export function configureAnimate($animateProvider) { - $animateProvider.register("fade", { - enter: [{ opacity: 1 }], - leave: [{ opacity: 0 }], - }); -} - -/** - * @param {ng.AriaProvider} $ariaProvider - */ -export function configureAria($ariaProvider) { - $ariaProvider.config({ tabindex: false }); -} - -/** - * @param {ng.HttpParamSerializerProvider} $httpParamSerializerProvider - */ -export function configureHttpParamSerializer($httpParamSerializerProvider) { - $httpParamSerializerProvider.$get = - () => - (params = {}) => - new URLSearchParams( - /** @type {Record} */ (params), - ).toString(); -} - -/** - * @param {ng.SceProvider} $sceProvider - */ -export function configureSce($sceProvider) { - return $sceProvider.enabled(false); -} diff --git a/test/public-policy-types-consumer.ts b/test/public-policy-types-consumer.ts new file mode 100644 index 000000000..53557c1df --- /dev/null +++ b/test/public-policy-types-consumer.ts @@ -0,0 +1,17 @@ +import type { + Policy, + PolicyContext, + PolicyDecision, +} from "@angular-wave/angular.ts"; + +type AuditContext = PolicyContext<"audit">; + +const decision: PolicyDecision<"allow"> = { + type: "allow", +}; +const policy: Policy = (context) => ({ + ...decision, + meta: context.meta, +}); + +void policy; diff --git a/utils/server/go.mod b/utils/server/go.mod index b0d58d125..68b3ecced 100644 --- a/utils/server/go.mod +++ b/utils/server/go.mod @@ -1,6 +1,6 @@ module angular-ts-test-server -go 1.24.3 +go 1.25.0 require ( github.com/quic-go/quic-go v0.59.1 @@ -10,8 +10,8 @@ require ( require ( github.com/dunglas/httpsfv v1.1.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/utils/server/go.sum b/utils/server/go.sum index 26e3631b6..e2b2a4406 100644 --- a/utils/server/go.sum +++ b/utils/server/go.sum @@ -14,13 +14,13 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=